diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index 30eedc56b..f5df2421e 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -1085,9 +1085,15 @@ export class MasterPlaylistController extends videojs.EventTarget { return false; } + let expired = this.syncController_.getExpiredTime(playlist, this.mediaSource.duration); + + if (expired === null) { + return false; + } + // does not use the safe live end to calculate playlist end, since we // don't want to say we are stuck while there is still content - let absolutePlaylistEnd = Hls.Playlist.playlistEnd(playlist); + let absolutePlaylistEnd = Hls.Playlist.playlistEnd(playlist, expired); let currentTime = this.tech_.currentTime(); let buffered = this.tech_.buffered(); @@ -1251,7 +1257,6 @@ export class MasterPlaylistController extends videojs.EventTarget { } onSyncInfoUpdate_() { - let media; let mainSeekable; let audioSeekable; @@ -1259,19 +1264,35 @@ export class MasterPlaylistController extends videojs.EventTarget { return; } - media = this.masterPlaylistLoader_.media(); + let media = this.masterPlaylistLoader_.media(); if (!media) { return; } - mainSeekable = Hls.Playlist.seekable(media); + let expired = this.syncController_.getExpiredTime(media, this.mediaSource.duration); + + if (expired === null) { + // not enough information to update seekable + return; + } + + mainSeekable = Hls.Playlist.seekable(media, expired); + if (mainSeekable.length === 0) { return; } if (this.audioPlaylistLoader_) { - audioSeekable = Hls.Playlist.seekable(this.audioPlaylistLoader_.media()); + media = this.audioPlaylistLoader_.media(); + expired = this.syncController_.getExpiredTime(media, this.mediaSource.duration); + + if (expired === null) { + return; + } + + audioSeekable = Hls.Playlist.seekable(media, expired); + if (audioSeekable.length === 0) { return; } diff --git a/src/playlist.js b/src/playlist.js index b9b327b03..19268ee95 100644 --- a/src/playlist.js +++ b/src/playlist.js @@ -219,99 +219,12 @@ export const sumDurations = function(playlist, startIndex, endIndex) { return durations; }; -/** - * Returns an array with two sync points. The first being an expired sync point, which is - * the most recent segment with timing sync data that has fallen off the playlist. The - * second is a segment sync point, which is the first segment that has timing sync data in - * the current playlist. - * - * @param {Object} playlist a media playlist object - * @returns {Object} an object containing the two sync points - * @returns {Object.expiredSync|null} sync point data from an expired segment - * @returns {Object.segmentSync|null} sync point data from a segment in the playlist - * @function getPlaylistSyncPoints - */ -const getPlaylistSyncPoints = function(playlist) { - if (!playlist || !playlist.segments) { - return [null, null]; - } - let expiredSync = playlist.syncInfo || (playlist.endList ? { time: 0, mediaSequence: 0} : null); - let segmentSync = null; - - // Find the first segment with timing information - for (let i = 0, l = playlist.segments.length; i < l; i++) { - let segment = playlist.segments[i]; - - if (typeof segment.start !== 'undefined') { - segmentSync = { - mediaSequence: playlist.mediaSequence + i, - time: segment.start - }; - break; - } - } - - return { expiredSync, segmentSync }; -}; - -/** - * Calculates the amount of time expired from the playlist based on the provided - * sync points. - * - * @param {Object} playlist a media playlist object - * @param {Object|null} expiredSync sync point representing most recent segment with - * timing sync data that has fallen off the playlist - * @param {Object|null} segmentSync sync point representing the first segment that has - * timing sync data in the playlist - * @returns {Number} the amount of time expired from the playlist - * @function calculateExpiredTime - */ -const calculateExpiredTime = function(playlist) { - // If we have both an expired sync point and a segment sync point - // determine which sync point is closest to the start of the playlist - // so the minimal amount of timing estimation is done. - let { expiredSync, segmentSync } = getPlaylistSyncPoints(playlist); - - if (expiredSync && segmentSync) { - let expiredDiff = expiredSync.mediaSequence - playlist.mediaSequence; - let segmentDiff = segmentSync.mediaSequence - playlist.mediaSequence; - let syncIndex; - let syncTime; - - if (Math.abs(expiredDiff) > Math.abs(segmentDiff)) { - syncIndex = segmentDiff; - syncTime = -segmentSync.time; - } else { - syncIndex = expiredDiff; - syncTime = expiredSync.time; - } - - return Math.abs(syncTime + sumDurations(playlist, syncIndex, 0)); - } - - // We only have an expired sync point, so base expired time on the expired sync point - // and estimate the time from that sync point to the start of the playlist. - if (expiredSync) { - let syncIndex = expiredSync.mediaSequence - playlist.mediaSequence; - - return expiredSync.time + sumDurations(playlist, syncIndex, 0); - } - - // We only have a segment sync point, so base expired time on the first segment we have - // sync point data for and estimate the time from that media index to the start of the - // playlist. - if (segmentSync) { - let syncIndex = segmentSync.mediaSequence - playlist.mediaSequence; - - return segmentSync.time - sumDurations(playlist, syncIndex, 0); - } - return null; -}; - /** * Calculates the playlist end time * * @param {Object} playlist a media playlist object + * @param {Number=} expired the amount of time that has + * dropped off the front of the playlist in a live scenario * @param {Boolean|false} useSafeLiveEnd a boolean value indicating whether or not the playlist * end calculation should consider the safe live end (truncate the playlist * end by three segments). This is normally used for calculating the end of @@ -319,18 +232,20 @@ const calculateExpiredTime = function(playlist) { * @returns {Number} the end time of playlist * @function playlistEnd */ -export const playlistEnd = function(playlist, useSafeLiveEnd) { +export const playlistEnd = function(playlist, expired, useSafeLiveEnd) { if (!playlist || !playlist.segments) { return null; } if (playlist.endList) { return duration(playlist); } - let expired = calculateExpiredTime(playlist); if (expired === null) { return null; } + + expired = expired || 0; + let endSequence = useSafeLiveEnd ? Math.max(0, playlist.segments.length - Playlist.UNSAFE_LIVE_SEGMENTS) : Math.max(0, playlist.segments.length); @@ -349,13 +264,15 @@ export const playlistEnd = function(playlist, useSafeLiveEnd) { * * @param {Object} playlist a media playlist object * dropped off the front of the playlist in a live scenario + * @param {Number=} expired the amount of time that has + * dropped off the front of the playlist in a live scenario * @return {TimeRanges} the periods of time that are valid targets * for seeking */ -export const seekable = function(playlist) { +export const seekable = function(playlist, expired) { let useSafeLiveEnd = true; - let seekableStart = calculateExpiredTime(playlist); - let seekableEnd = playlistEnd(playlist, useSafeLiveEnd); + let seekableStart = expired || 0; + let seekableEnd = playlistEnd(playlist, expired, useSafeLiveEnd); if (seekableEnd === null) { return createTimeRange(); diff --git a/src/sync-controller.js b/src/sync-controller.js index cdb64f576..adefa9091 100644 --- a/src/sync-controller.js +++ b/src/sync-controller.js @@ -158,18 +158,99 @@ export default class SyncController extends videojs.EventTarget { * A sync-point is defined as a known mapping from display-time to * a segment-index in the current playlist. * - * @param {Playlist} media - 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 - * @returns {Object} - A sync-point object + * @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 + * @returns {Object} + * A sync-point object */ getSyncPoint(playlist, duration, currentTimeline, currentTime) { + const syncPoints = this.runStrategies_(playlist, + duration, + currentTimeline, + currentTime); + + 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; + } + + // 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) + * @returns {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(playlist, syncPoint.segmentIndex, 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 + * @returns {Array} + * A list of sync-point objects + */ + runStrategies_(playlist, duration, currentTimeline, currentTime) { let syncPoints = []; // Try to find a sync-point in by utilizing various strategies... for (let i = 0; i < syncPointStrategies.length; i++) { let strategy = syncPointStrategies[i]; - let syncPoint = strategy.run(this, playlist, duration, currentTimeline, currentTime); + let syncPoint = strategy.run(this, + playlist, + duration, + currentTimeline, + currentTime); if (syncPoint) { syncPoint.strategy = strategy.name; @@ -181,22 +262,31 @@ export default class SyncController extends videojs.EventTarget { } } - 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; - } + return syncPoints; + } - // 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 + /** + * 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. + * @returns {Object} + * The sync-point nearest the target + */ + selectSyncPoint_(syncPoints, target) { let bestSyncPoint = syncPoints[0].syncPoint; - let bestDistance = Math.abs(syncPoints[0].syncPoint.time - currentTime); + let bestDistance = Math.abs(syncPoints[0].syncPoint[target.key] - target.value); let bestStrategy = syncPoints[0].strategy; for (let i = 1; i < syncPoints.length; i++) { - let newDistance = Math.abs(syncPoints[i].syncPoint.time - currentTime); + let newDistance = Math.abs(syncPoints[i].syncPoint[target.key] - target.value); if (newDistance < bestDistance) { bestDistance = newDistance; @@ -204,6 +294,7 @@ export default class SyncController extends videojs.EventTarget { bestStrategy = syncPoints[i].strategy; } } + this.logger_(`syncPoint with strategy <${bestStrategy}> chosen: `, bestSyncPoint); return bestSyncPoint; } @@ -351,8 +442,12 @@ export default class SyncController extends videojs.EventTarget { * 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 {SegmentInfo} segmentInfo + * The current active request information + * @param {object} timingInfo + * The start and end time of the current segment in "media time" + * @returns {Boolean} + * Returns false if segment time mapping could not be calculated */ calculateSegmentTimeMapping_(segmentInfo, timingInfo) { let segment = segmentInfo.segment; @@ -411,17 +506,22 @@ export default class SyncController extends videojs.EventTarget { if (!this.discontinuities[discontinuity] || this.discontinuities[discontinuity].accuracy > accuracy) { + let time; + if (mediaIndexDiff < 0) { - this.discontinuities[discontinuity] = { - time: segment.start - sumDurations(playlist, segmentInfo.mediaIndex, segmentIndex), - accuracy - }; + time = segment.start - sumDurations(playlist, + segmentInfo.mediaIndex, + segmentIndex); } else { - this.discontinuities[discontinuity] = { - time: segment.end + sumDurations(playlist, segmentInfo.mediaIndex + 1, segmentIndex), - accuracy - }; + time = segment.end + sumDurations(playlist, + segmentInfo.mediaIndex + 1, + segmentIndex); } + + this.discontinuities[discontinuity] = { + time, + accuracy + }; } } } diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index 224c55ac0..6d6b8a3b7 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -491,6 +491,7 @@ QUnit.test('detects if the player is stuck at the playlist end', function(assert // not stuck at playlist end when currentTime not at seekable end // even if the buffer is empty this.masterPlaylistController.seekable = () => videojs.createTimeRange(0, 130); + this.masterPlaylistController.syncController_.getExpiredTime = () => 0; this.player.tech_.setCurrentTime(50); this.player.tech_.buffered = () => videojs.createTimeRange(); Hls.Playlist.playlistEnd = () => 130; @@ -877,6 +878,7 @@ function(assert) { }; this.masterPlaylistController.masterPlaylistLoader_.media = () => mainMedia; + this.masterPlaylistController.syncController_.getExpiredTime = () => 0; Playlist.seekable = (media) => { if (media === mainMedia) { @@ -1014,6 +1016,7 @@ function(assert) { return videojs.createTimeRanges(mainTimeRanges); }; this.masterPlaylistController.masterPlaylistLoader_.media = () => media; + this.masterPlaylistController.syncController_.getExpiredTime = () => 0; mainTimeRanges = [[0, 10]]; mpc.seekable_ = videojs.createTimeRanges(); diff --git a/test/playlist.test.js b/test/playlist.test.js index 8ee9e614c..77c739285 100644 --- a/test/playlist.test.js +++ b/test/playlist.test.js @@ -387,6 +387,36 @@ QUnit.test('seekable end and playlist end account for non-standard target durati assert.equal(playlistEnd, 9, 'playlist end at the last segment'); }); +QUnit.test('seekable end and playlist end account for non-zero starting VOD media sequence', function(assert) { + let playlist = { + targetDuration: 2, + mediaSequence: 5, + endList: true, + segments: [{ + duration: 2, + uri: '0.ts' + }, { + duration: 2, + uri: '1.ts' + }, { + duration: 1, + uri: '2.ts' + }, { + duration: 2, + uri: '3.ts' + }, { + duration: 2, + uri: '4.ts' + }] + }; + let seekable = Playlist.seekable(playlist); + let playlistEnd = Playlist.playlistEnd(playlist); + + assert.equal(seekable.start(0), 0, 'starts at the earliest available segment'); + assert.equal(seekable.end(0), 9, 'seekable end is same as duration'); + assert.equal(playlistEnd, 9, 'playlist end at the last segment'); +}); + QUnit.test('playlist with no sync points has empty seekable range and empty playlist end', function(assert) { let playlist = { targetDuration: 10, @@ -405,8 +435,12 @@ QUnit.test('playlist with no sync points has empty seekable range and empty play uri: '3.ts' }] }; - let seekable = Playlist.seekable(playlist); - let playlistEnd = Playlist.playlistEnd(playlist); + + // seekable and playlistEnd take an optional expired parameter that is from + // SyncController.getExpiredTime which returns null when there is no sync point, so + // this test passes in null to simulate no sync points + let seekable = Playlist.seekable(playlist, null); + let playlistEnd = Playlist.playlistEnd(playlist, null); assert.equal(seekable.length, 0, 'no seekable range for playlist with no sync points'); assert.equal(playlistEnd, null, 'no playlist end for playlist with no sync points'); @@ -444,8 +478,10 @@ QUnit.test('seekable and playlistEnd use available sync points for calculating', } ] }; - let seekable = Playlist.seekable(playlist); - let playlistEnd = Playlist.playlistEnd(playlist); + + // getExpiredTime would return 100 for this playlist + let seekable = Playlist.seekable(playlist, 100); + let playlistEnd = Playlist.playlistEnd(playlist, 100); assert.ok(seekable.length, 'seekable range calculated'); assert.equal(seekable.start(0), 100, 'estimated start time based on expired sync point'); @@ -480,8 +516,10 @@ QUnit.test('seekable and playlistEnd use available sync points for calculating', } ] }; - seekable = Playlist.seekable(playlist); - playlistEnd = Playlist.playlistEnd(playlist); + + // getExpiredTime would return 98.5 + seekable = Playlist.seekable(playlist, 98.5); + playlistEnd = Playlist.playlistEnd(playlist, 98.5); assert.ok(seekable.length, 'seekable range calculated'); assert.equal(seekable.start(0), 98.5, 'estimated start time using segmentSync'); @@ -520,8 +558,10 @@ QUnit.test('seekable and playlistEnd use available sync points for calculating', } ] }; - seekable = Playlist.seekable(playlist); - playlistEnd = Playlist.playlistEnd(playlist); + + // getExpiredTime would return 98.5 + seekable = Playlist.seekable(playlist, 98.5); + playlistEnd = Playlist.playlistEnd(playlist, 98.5); assert.ok(seekable.length, 'seekable range calculated'); assert.equal(seekable.start(0), 98.5, 'estimated start time using nearest sync point (segmentSync in this case)'); @@ -560,8 +600,10 @@ QUnit.test('seekable and playlistEnd use available sync points for calculating', } ] }; - seekable = Playlist.seekable(playlist); - playlistEnd = Playlist.playlistEnd(playlist); + + // getExpiredTime would return 100.8 + seekable = Playlist.seekable(playlist, 100.8); + playlistEnd = Playlist.playlistEnd(playlist, 100.8); assert.ok(seekable.length, 'seekable range calculated'); assert.equal(seekable.start(0), 100.8, 'estimated start time using nearest sync point (expiredSync in this case)'); diff --git a/test/sync-controller.test.js b/test/sync-controller.test.js index cadac3c8a..fa99ec9e5 100644 --- a/test/sync-controller.test.js +++ b/test/sync-controller.test.js @@ -158,10 +158,11 @@ QUnit.test('returns correct sync point for Playlist strategy', function(assert) playlist.syncInfo = { time: 10, mediaSequence: 100}; syncPoint = strategy.run(this.syncController, playlist, 40, 0); - assert.deepEqual(syncPoint, { time: 10, segmentIndex: -2 }, 'found sync point in playlist'); + assert.deepEqual(syncPoint, { time: 10, segmentIndex: -2 }, + 'found sync point in playlist'); }); -QUnit.test('saves expired info onto new playlist for possible sync point', function(assert) { +QUnit.test('saves expired info onto new playlist for sync point', function(assert) { let oldPlaylist = playlistWithDuration(50); let newPlaylist = playlistWithDuration(50); @@ -249,3 +250,219 @@ QUnit.test('Correctly updates time mapping and discontinuity info when probing s assert.deepEqual(syncCon.discontinuities[1], { time: 30, accuracy: 0 }, 'discontinuity sync info correctly updated with new accuracy'); }); + +QUnit.test('Correctly calculates expired time', function(assert) { + let playlist = { + targetDuration: 10, + mediaSequence: 100, + discontinuityStarts: [], + syncInfo: { + time: 50, + mediaSequence: 95 + }, + segments: [ + { + duration: 10, + uri: '0.ts' + }, + { + duration: 10, + uri: '1.ts' + }, + { + duration: 10, + uri: '2.ts' + }, + { + duration: 10, + uri: '3.ts' + }, + { + duration: 10, + uri: '4.ts' + } + ] + }; + + let expired = this.syncController.getExpiredTime(playlist, Infinity); + + assert.equal(expired, 100, 'estimated expired time using segmentSync'); + + playlist = { + targetDuration: 10, + discontinuityStarts: [], + mediaSequence: 100, + segments: [ + { + duration: 10, + uri: '0.ts' + }, + { + duration: 10, + uri: '1.ts', + start: 108.5, + end: 118.4 + }, + { + duration: 10, + uri: '2.ts' + }, + { + duration: 10, + uri: '3.ts' + }, + { + duration: 10, + uri: '4.ts' + } + ] + }; + + expired = this.syncController.getExpiredTime(playlist, Infinity); + + assert.equal(expired, 98.5, 'estimated expired time using segmentSync'); + + playlist = { + discontinuityStarts: [], + targetDuration: 10, + mediaSequence: 100, + syncInfo: { + time: 50, + mediaSequence: 95 + }, + segments: [ + { + duration: 10, + uri: '0.ts' + }, + { + duration: 10, + uri: '1.ts', + start: 108.5, + end: 118.5 + }, + { + duration: 10, + uri: '2.ts' + }, + { + duration: 10, + uri: '3.ts' + }, + { + duration: 10, + uri: '4.ts' + } + ] + }; + + expired = this.syncController.getExpiredTime(playlist, Infinity); + + assert.equal(expired, 98.5, 'estimated expired time using segmentSync'); + + playlist = { + targetDuration: 10, + discontinuityStarts: [], + mediaSequence: 100, + syncInfo: { + time: 90.8, + mediaSequence: 99 + }, + segments: [ + { + duration: 10, + uri: '0.ts' + }, + { + duration: 10, + uri: '1.ts' + }, + { + duration: 10, + uri: '2.ts', + start: 118.5, + end: 128.5 + }, + { + duration: 10, + uri: '3.ts' + }, + { + duration: 10, + uri: '4.ts' + } + ] + }; + + expired = this.syncController.getExpiredTime(playlist, Infinity); + + assert.equal(expired, 100.8, 'estimated expired time using segmentSync'); + + playlist = { + targetDuration: 10, + discontinuityStarts: [], + mediaSequence: 100, + endList: true, + segments: [ + { + duration: 10, + uri: '0.ts' + }, + { + duration: 10, + uri: '1.ts' + }, + { + duration: 10, + uri: '2.ts' + }, + { + duration: 10, + uri: '3.ts' + }, + { + duration: 10, + uri: '4.ts' + } + ] + }; + + expired = this.syncController.getExpiredTime(playlist, 50); + + assert.equal(expired, 0, 'estimated expired time using segmentSync'); + + playlist = { + targetDuration: 10, + discontinuityStarts: [], + mediaSequence: 100, + endList: true, + segments: [ + { + start: 0.006, + duration: 10, + uri: '0.ts', + end: 9.982 + }, + { + duration: 10, + uri: '1.ts' + }, + { + duration: 10, + uri: '2.ts' + }, + { + duration: 10, + uri: '3.ts' + }, + { + duration: 10, + uri: '4.ts' + } + ] + }; + + expired = this.syncController.getExpiredTime(playlist, 50); + + assert.equal(expired, 0, 'estimated expired time using segmentSync'); +});