diff --git a/src/master-playlist-controller.js b/src/master-playlist-controller.js index d620c115d..20fb0ab39 100644 --- a/src/master-playlist-controller.js +++ b/src/master-playlist-controller.js @@ -47,8 +47,9 @@ const sumLoaderStat = function(stat) { }; const shouldSwitchToMedia = function({ currentPlaylist, + buffered, + currentTime, nextPlaylist, - forwardBuffer, bufferLowWaterLine, bufferHighWaterLine, duration, @@ -73,15 +74,25 @@ const shouldSwitchToMedia = function({ return false; } + // determine if current time is in a buffered range. + const isBuffered = Boolean(Ranges.findRange(buffered, currentTime).length); + // If the playlist is live, then we want to not take low water line into account. // This is because in LIVE, the player plays 3 segments from the end of the // playlist, and if `BUFFER_LOW_WATER_LINE` is greater than the duration availble // in those segments, a viewer will never experience a rendition upswitch. if (!currentPlaylist.endList) { + // For LLHLS live streams, don't switch renditions before playback has started, as it almost + // doubles the time to first playback. + if (!isBuffered && typeof currentPlaylist.partTargetDuration === 'number') { + log(`not ${sharedLogLine} as current playlist is live llhls, but currentTime isn't in buffered.`); + return false; + } log(`${sharedLogLine} as current playlist is live`); return true; } + const forwardBuffer = Ranges.timeAheadOf(buffered, currentTime); const maxBufferLowWaterLine = experimentalBufferBasedABR ? Config.EXPERIMENTAL_MAX_BUFFER_LOW_WATER_LINE : Config.MAX_BUFFER_LOW_WATER_LINE; @@ -732,18 +743,18 @@ export class MasterPlaylistController extends videojs.EventTarget { } shouldSwitchToMedia_(nextPlaylist) { - const currentPlaylist = this.masterPlaylistLoader_.media(); - const buffered = this.tech_.buffered(); - const forwardBuffer = buffered.length ? - buffered.end(buffered.length - 1) - this.tech_.currentTime() : 0; - + const currentPlaylist = this.masterPlaylistLoader_.media() || + this.masterPlaylistLoader_.pendingMedia_; + const currentTime = this.tech_.currentTime(); const bufferLowWaterLine = this.bufferLowWaterLine(); const bufferHighWaterLine = this.bufferHighWaterLine(); + const buffered = this.tech_.buffered(); return shouldSwitchToMedia({ + buffered, + currentTime, currentPlaylist, nextPlaylist, - forwardBuffer, bufferLowWaterLine, bufferHighWaterLine, duration: this.duration(), @@ -1434,7 +1445,9 @@ export class MasterPlaylistController extends videojs.EventTarget { onSyncInfoUpdate_() { let audioSeekable; - if (!this.masterPlaylistLoader_) { + // If we have two source buffers and only one is created then the seekable range will be incorrect. + // We should wait until all source buffers are created. + if (!this.masterPlaylistLoader_ || this.sourceUpdater_.hasCreatedSourceBuffers()) { return; } diff --git a/src/playback-watcher.js b/src/playback-watcher.js index 1afa1d957..afeb604f9 100644 --- a/src/playback-watcher.js +++ b/src/playback-watcher.js @@ -351,10 +351,15 @@ export default class PlaybackWatcher { const buffered = this.tech_.buffered(); const audioBuffered = sourceUpdater.audioBuffer ? sourceUpdater.audioBuffered() : null; const videoBuffered = sourceUpdater.videoBuffer ? sourceUpdater.videoBuffered() : null; + const media = this.media(); + + // verify that at least two segment durations or one part duration have been + // appended before checking for a gap. + const minAppendedDuration = media.partTargetDuration ? media.partTargetDuration : + (media.targetDuration - Ranges.TIME_FUDGE_FACTOR) * 2; // verify that at least two segment durations have been // appended before checking for a gap. - const twoSegmentDurations = (this.media().targetDuration - Ranges.TIME_FUDGE_FACTOR) * 2; const bufferedToCheck = [audioBuffered, videoBuffered]; for (let i = 0; i < bufferedToCheck.length; i++) { @@ -365,9 +370,9 @@ export default class PlaybackWatcher { const timeAhead = Ranges.timeAheadOf(bufferedToCheck[i], currentTime); - // if we are less than two video/audio segment durations behind, - // we haven't appended enough to call this a bad seek. - if (timeAhead < twoSegmentDurations) { + // if we are less than two video/audio segment durations or one part + // duration behind we haven't appended enough to call this a bad seek. + if (timeAhead < minAppendedDuration) { return false; } } diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 07bab9e7d..1f7556470 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -257,7 +257,8 @@ const getAllSegments = function(media) { export const isPlaylistUnchanged = (a, b) => a === b || (a.segments && b.segments && a.segments.length === b.segments.length && a.endList === b.endList && - a.mediaSequence === b.mediaSequence); + a.mediaSequence === b.mediaSequence && + a.preloadSegment === b.preloadSegment); /** * Returns a new master playlist that is the result of merging an @@ -516,6 +517,8 @@ export default class PlaylistLoader extends EventTarget { this.targetDuration = playlist.partTargetDuration || playlist.targetDuration; + this.pendingMedia_ = null; + if (update) { this.master = update; this.media_ = this.master.playlists[id]; @@ -662,6 +665,8 @@ export default class PlaylistLoader extends EventTarget { this.trigger('mediachanging'); } + this.pendingMedia_ = playlist; + this.request = this.vhs_.xhr({ uri: playlist.resolvedUri, withCredentials: this.withCredentials diff --git a/src/playlist.js b/src/playlist.js index 4bbe5f99b..8aaed8f3b 100644 --- a/src/playlist.js +++ b/src/playlist.js @@ -10,6 +10,43 @@ import {TIME_FUDGE_FACTOR} from './ranges.js'; const {createTimeRange} = videojs; +/** + * Get the duration of a segment, with special cases for + * llhls segments that do not have a duration yet. + * + * @param {Object} playlist + * the playlist that the segment belongs to. + * @param {Object} segment + * the segment to get a duration for. + * + * @return {number} + * the segment duration + */ +export const segmentDurationWithParts = (playlist, segment) => { + // if this isn't a preload segment + // then we will have a segment duration that is accurate. + if (!segment.preload) { + return segment.duration; + } + + // otherwise we have to add up parts and preload hints + // to get an up to date duration. + let result = 0; + + (segment.parts || []).forEach(function(p) { + result += p.duration; + }); + + // for preload hints we have to use partTargetDuration + // as they won't even have a duration yet. + (segment.preloadHints || []).forEach(function(p) { + if (p.type === 'PART') { + result += playlist.partTargetDuration; + } + }); + + return result; +}; /** * A function to get a combined list of parts and segments with durations * and indexes. @@ -117,7 +154,7 @@ const backwardDuration = function(playlist, endSequence) { return { result: result + segment.end, precise: true }; } - result += segment.duration; + result += segmentDurationWithParts(playlist, segment); if (typeof segment.start !== 'undefined') { return { result: result + segment.start, precise: true }; @@ -149,7 +186,7 @@ const forwardDuration = function(playlist, endSequence) { }; } - result += segment.duration; + result += segmentDurationWithParts(playlist, segment); if (typeof segment.end !== 'undefined') { return { @@ -321,7 +358,7 @@ export const playlistEnd = function(playlist, expired, useSafeLiveEnd, liveEdgeP expired = expired || 0; - let lastSegmentTime = intervalDuration( + let lastSegmentEndTime = intervalDuration( playlist, playlist.mediaSequence + playlist.segments.length, expired @@ -329,11 +366,11 @@ export const playlistEnd = function(playlist, expired, useSafeLiveEnd, liveEdgeP if (useSafeLiveEnd) { liveEdgePadding = typeof liveEdgePadding === 'number' ? liveEdgePadding : liveEdgeDelay(null, playlist); - lastSegmentTime -= liveEdgePadding; + lastSegmentEndTime -= liveEdgePadding; } // don't return a time less than zero - return Math.max(0, lastSegmentTime); + return Math.max(0, lastSegmentEndTime); }; /** @@ -737,5 +774,6 @@ export default { estimateSegmentRequestTime, isLowestEnabledRendition, isAudioOnly, - playlistMatch + playlistMatch, + segmentDurationWithParts }; diff --git a/src/segment-loader.js b/src/segment-loader.js index 8c1a3f249..c6952a2a8 100644 --- a/src/segment-loader.js +++ b/src/segment-loader.js @@ -22,8 +22,7 @@ import { import { gopsSafeToAlignWith, removeGopBuffer, updateGopBuffer } from './util/gops'; import shallowEqual from './util/shallow-equal.js'; import { QUOTA_EXCEEDED_ERR } from './error-codes'; -import { timeRangesToArray } from './ranges'; -import {lastBufferedEnd} from './ranges.js'; +import {timeRangesToArray, lastBufferedEnd, timeAheadOf} from './ranges.js'; import {getKnownPartCount} from './playlist.js'; /** @@ -135,7 +134,7 @@ export const safeBackBufferTrimTime = (seekable, currentTime, targetDuration) => return Math.min(maxTrimTime, trimTime); }; -const segmentInfoString = (segmentInfo) => { +export const segmentInfoString = (segmentInfo) => { const { startOfSegment, duration, @@ -160,6 +159,10 @@ const segmentInfoString = (segmentInfo) => { selection = 'getSyncSegmentCandidate (isSyncRequest)'; } + if (segmentInfo.independent) { + selection += ` with independent ${segmentInfo.independent}`; + } + const hasPartIndex = typeof partIndex === 'number'; const name = segmentInfo.segment.uri ? 'segment' : 'pre-segment'; const zeroBasedPartCount = hasPartIndex ? getKnownPartCount({preloadSegment: segment}) - 1 : 0; @@ -1024,9 +1027,20 @@ export default class SegmentLoader extends videojs.EventTarget { if (!oldPlaylist || oldPlaylist.uri !== newPlaylist.uri) { if (this.mediaIndex !== null) { - // we must "resync" the segment loader when we switch renditions and + // we must reset/resync the segment loader when we switch renditions and // the segment loader is already synced to the previous rendition - this.resyncLoader(); + + // on playlist changes we want it to be possible to fetch + // at the buffer for vod but not for live. So we use resetLoader + // for live and resyncLoader for vod. We want this because + // if a playlist uses independent and non-independent segments/parts the + // buffer may not accurately reflect the next segment that we should try + // downloading. + if (!newPlaylist.endList) { + this.resetLoader(); + } else { + this.resyncLoader(); + } } this.currentMediaInfo_ = void 0; this.trigger('playlistupdate'); @@ -1366,8 +1380,9 @@ export default class SegmentLoader extends videojs.EventTarget { * @return {Object} a request object that describes the segment/part to load */ chooseNextRequest_() { - const bufferedEnd = lastBufferedEnd(this.buffered_()) || 0; - const bufferedTime = Math.max(0, bufferedEnd - this.currentTime_()); + const buffered = this.buffered_(); + const bufferedEnd = lastBufferedEnd(buffered) || 0; + const bufferedTime = timeAheadOf(buffered, this.currentTime_()); const preloaded = !this.hasPlayed_() && bufferedTime >= 1; const haveEnoughBuffer = bufferedTime >= this.goalBufferLength_(); const segments = this.playlist_.segments; @@ -1420,14 +1435,15 @@ export default class SegmentLoader extends videojs.EventTarget { startTime: this.syncPoint_.time }); - next.getMediaInfoForTime = this.fetchAtBuffer_ ? 'bufferedEnd' : 'currentTime'; + next.getMediaInfoForTime = this.fetchAtBuffer_ ? + `bufferedEnd ${bufferedEnd}` : `currentTime ${this.currentTime_()}`; next.mediaIndex = segmentIndex; next.startOfSegment = startTime; next.partIndex = partIndex; } const nextSegment = segments[next.mediaIndex]; - const nextPart = nextSegment && + let nextPart = nextSegment && typeof next.partIndex === 'number' && nextSegment.parts && nextSegment.parts[next.partIndex]; @@ -1442,6 +1458,28 @@ export default class SegmentLoader extends videojs.EventTarget { // Set partIndex to 0 if (typeof next.partIndex !== 'number' && nextSegment.parts) { next.partIndex = 0; + nextPart = nextSegment.parts[0]; + } + + // if we have no buffered data then we need to make sure + // that the next part we append is "independent" if possible. + // So we check if the previous part is independent, and request + // it if it is. + if (!bufferedTime && nextPart && !nextPart.independent) { + + if (next.partIndex === 0) { + const lastSegment = segments[next.mediaIndex - 1]; + const lastSegmentLastPart = lastSegment.parts && lastSegment.parts.length && lastSegment.parts[lastSegment.parts.length - 1]; + + if (lastSegmentLastPart && lastSegmentLastPart.independent) { + next.mediaIndex -= 1; + next.partIndex = lastSegment.parts.length - 1; + next.independent = 'previous segment'; + } + } else if (nextSegment.parts[next.partIndex - 1].independent) { + next.partIndex -= 1; + next.independent = 'previous part'; + } } const ended = this.mediaSource_ && this.mediaSource_.readyState === 'ended'; @@ -1459,6 +1497,7 @@ export default class SegmentLoader extends videojs.EventTarget { generateSegmentInfo_(options) { const { + independent, playlist, mediaIndex, startOfSegment, @@ -1499,7 +1538,8 @@ export default class SegmentLoader extends videojs.EventTarget { byteLength: 0, transmuxer: this.transmuxer_, // type of getMediaInfoForTime that was used to get this segment - getMediaInfoForTime + getMediaInfoForTime, + independent }; const overrideCheck = @@ -1991,7 +2031,7 @@ export default class SegmentLoader extends videojs.EventTarget { this.setTimeMapping_(segmentInfo.timeline); // for tracking overall stats - this.updateMediaSecondsLoaded_(segmentInfo.segment); + this.updateMediaSecondsLoaded_(segmentInfo.part || segmentInfo.segment); // Note that the state isn't changed from loading to appending. This is because abort // logic may change behavior depending on the state, and changing state too early may @@ -2995,15 +3035,19 @@ export default class SegmentLoader extends videojs.EventTarget { // and attempt to resync when the post-update seekable window and live // point would mean that this was the perfect segment to fetch this.trigger('syncinfoupdate'); - const segment = segmentInfo.segment; + const part = segmentInfo.part; + const badSegmentGuess = segment.end && + this.currentTime_() - segment.end > segmentInfo.playlist.targetDuration * 3; + const badPartGuess = part && + part.end && this.currentTime_() - part.end > segmentInfo.playlist.partTargetDuration * 3; - // If we previously appended a segment that ends more than 3 targetDurations before + // If we previously appended a segment/part that ends more than 3 part/targetDurations before // the currentTime_ that means that our conservative guess was too conservative. // In that case, reset the loader state so that we try to use any information gained // from the previous request to create a new, more accurate, sync-point. - if (segment.end && - this.currentTime_() - segment.end > segmentInfo.playlist.targetDuration * 3) { + if (badSegmentGuess || badPartGuess) { + this.logger_(`bad ${badSegmentGuess ? 'segment' : 'part'} ${segmentInfoString(segmentInfo)}`); this.resetEverything(); return; } diff --git a/src/sync-controller.js b/src/sync-controller.js index ff7d2a82c..b280d75bd 100644 --- a/src/sync-controller.js +++ b/src/sync-controller.js @@ -53,35 +53,33 @@ export const syncPointStrategies = [ const datetimeMapping = syncController.timelineToDatetimeMappings[segment.timeline]; - if (!datetimeMapping) { + if (!datetimeMapping || !segment.dateTimeObject) { continue; } - if (segment.dateTimeObject) { - const segmentTime = segment.dateTimeObject.getTime() / 1000; - let start = segmentTime + datetimeMapping; + 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; + // 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); - lastDistance = distance; - syncPoint = { - time: start, - segmentIndex: partAndSegment.segmentIndex, - partIndex: partAndSegment.partIndex - }; + // 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; } diff --git a/test/loader-common.js b/test/loader-common.js index 6ddd9c7f8..367cc9261 100644 --- a/test/loader-common.js +++ b/test/loader-common.js @@ -835,6 +835,88 @@ export const LoaderCommonFactory = ({ }); }); + QUnit.test('live rendition switch uses resetLoader', function(assert) { + return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => { + + loader.playlist(playlistWithDuration(50, { + mediaSequence: 0, + endList: false + })); + + loader.load(); + loader.mediaIndex = 0; + let resyncCalled = false; + let resetCalled = false; + const origReset = loader.resetLoader; + const origResync = loader.resyncLoader; + + loader.resetLoader = function() { + resetCalled = true; + return origReset.call(loader); + }; + + loader.resyncLoader = function() { + resyncCalled = true; + return origResync.call(loader); + }; + + const newPlaylist = playlistWithDuration(50, { + mediaSequence: 0, + endList: false + }); + + newPlaylist.uri = 'playlist2.m3u8'; + + loader.playlist(newPlaylist); + + assert.true(resetCalled, 'reset was called'); + assert.true(resyncCalled, 'resync was called'); + + return Promise.resolve(); + }); + }); + + QUnit.test('vod rendition switch uses resyncLoader', function(assert) { + return this.setupMediaSource(loader.mediaSource_, loader.sourceUpdater_).then(() => { + + loader.playlist(playlistWithDuration(50, { + mediaSequence: 0, + endList: true + })); + + loader.load(); + loader.mediaIndex = 0; + let resyncCalled = false; + let resetCalled = false; + const origReset = loader.resetLoader; + const origResync = loader.resyncLoader; + + loader.resetLoader = function() { + resetCalled = true; + return origReset.call(loader); + }; + + loader.resyncLoader = function() { + resyncCalled = true; + return origResync.call(loader); + }; + + const newPlaylist = playlistWithDuration(50, { + mediaSequence: 0, + endList: true + }); + + newPlaylist.uri = 'playlist2.m3u8'; + + loader.playlist(newPlaylist); + + assert.true(resyncCalled, 'resync was called'); + assert.false(resetCalled, 'reset was not called'); + + return Promise.resolve(); + }); + }); + // only main/fmp4 segment loaders use async appends and parts/partIndex if (usesAsyncAppends) { let testFn = 'test'; @@ -1275,6 +1357,35 @@ export const LoaderCommonFactory = ({ } ); + QUnit.test('chooses the previous part if not buffered and current is not independent', function(assert) { + loader.buffered_ = () => videojs.createTimeRanges(); + const playlist = playlistWithDuration(50, {llhls: true}); + + loader.hasPlayed_ = () => true; + loader.syncPoint_ = null; + + loader.playlist(playlist); + loader.load(); + + // force segmentIndex 4 and part 2 to be choosen + loader.currentTime_ = () => 46; + // make the previous part indepenent so we go back to it + playlist.segments[4].parts[1].independent = true; + const segmentInfo = loader.chooseNextRequest_(); + + assert.equal(segmentInfo.partIndex, 1, 'still chooses partIndex 1'); + assert.equal(segmentInfo.mediaIndex, 4, 'same segment'); + + // force segmentIndex 4 and part 0 to be choosen + loader.currentTime_ = () => 42; + // make the previous part independent + playlist.segments[3].parts[4].independent = true; + const segmentInfo2 = loader.chooseNextRequest_(); + + assert.equal(segmentInfo2.partIndex, 4, 'previous part'); + assert.equal(segmentInfo2.mediaIndex, 3, 'previous segment'); + }); + QUnit.test('processing segment reachable even after playlist update removes it', function(assert) { const handleAppendsDone_ = loader.handleAppendsDone_.bind(loader); let expectedURI = '0.ts'; diff --git a/test/master-playlist-controller.test.js b/test/master-playlist-controller.test.js index 3ebae6d85..aca68bbd2 100644 --- a/test/master-playlist-controller.test.js +++ b/test/master-playlist-controller.test.js @@ -6085,6 +6085,25 @@ QUnit.test('false without nextPlaylist', function(assert) { this.env.log.warn.callCount = 0; }); +QUnit.test('false if llhls playlist and no buffered', function(assert) { + const mpc = this.masterPlaylistController; + + mpc.masterPlaylistLoader_.media = () => ({id: 'foo', endList: false, partTargetDuration: 5}); + const nextPlaylist = {id: 'bar', endList: false, partTargetDuration: 5}; + + assert.notOk(mpc.shouldSwitchToMedia_(nextPlaylist), 'should not switch when nothing is buffered'); +}); + +QUnit.test('true if llhls playlist and we have buffered', function(assert) { + const mpc = this.masterPlaylistController; + + mpc.tech_.buffered = () => videojs.createTimeRange([[0, 10]]); + mpc.masterPlaylistLoader_.media = () => ({id: 'foo', endList: false, partTargetDuration: 5}); + const nextPlaylist = {id: 'bar', endList: false, partTargetDuration: 5}; + + assert.ok(mpc.shouldSwitchToMedia_(nextPlaylist), 'should switch if buffered'); +}); + QUnit.module('MasterPlaylistController blacklistCurrentPlaylist', sharedHooks); QUnit.test("don't exclude only playlist unless it was excluded forever", function(assert) { diff --git a/test/playback-watcher.test.js b/test/playback-watcher.test.js index 26393f93a..c5709aacf 100644 --- a/test/playback-watcher.test.js +++ b/test/playback-watcher.test.js @@ -1207,6 +1207,61 @@ QUnit.test('dispose stops bad seek handling', function(assert) { assert.equal(seeks.length, 0, 'no seeks'); }); +QUnit.test('part target duration is used for append verification', function(assert) { + // target duration is 10 for this manifest + this.player.src({ + src: 'liveStart30sBefore.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + + // start playback normally + this.player.tech_.triggerReady(); + this.clock.tick(1); + standardXHRResponse(this.requests.shift()); + openMediaSource(this.player, this.clock); + this.player.tech_.trigger('play'); + this.player.tech_.trigger('playing'); + this.clock.tick(1); + + const playbackWatcher = this.player.tech_.vhs.playbackWatcher_; + const seeks = []; + let currentTime; + let buffered; + + playbackWatcher.seekable = () => videojs.createTimeRanges([[10, 100]]); + playbackWatcher.tech_ = { + off: () => {}, + seeking: () => true, + setCurrentTime: (time) => { + seeks.push(time); + }, + currentTime: () => currentTime, + buffered: () => buffered + }; + + Object.assign(playbackWatcher.masterPlaylistController_.sourceUpdater_, { + videoBuffer: true, + videoBuffered: () => buffered + }); + + this.player.tech(true).vhs.setCurrentTime = (time) => seeks.push(time); + + const media = playbackWatcher.media(); + + media.partTargetDuration = 1.1; + + playbackWatcher.media = () => media; + + currentTime = 40; + buffered = videojs.createTimeRanges([[41, 42.1]]); + assert.ok( + playbackWatcher.fixesBadSeeks_(), + 'acts when close enough to, and enough, buffer' + ); + assert.equal(seeks.length, 1, 'seeked'); + assert.equal(seeks[0], 41.1, 'player seeked to the start of the closer buffer'); +}); + const loaderTypes = ['audio', 'main', 'subtitle']; const EXCLUDE_APPEND_COUNT = 10; diff --git a/test/playlist-loader.test.js b/test/playlist-loader.test.js index e501e69aa..eea3e9629 100644 --- a/test/playlist-loader.test.js +++ b/test/playlist-loader.test.js @@ -172,6 +172,7 @@ QUnit.module('Playlist Loader', function(hooks) { }] }] }; + const media = { mediaSequence: 1, attributes: { @@ -648,6 +649,152 @@ QUnit.module('Playlist Loader', function(hooks) { ); }); + QUnit.test('updateMaster detects preload segment changes', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }], + preloadSegment: { + parts: [ + {uri: 'part-0-uri'} + ] + } + }] + }; + const media = { + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + segments: [{ + duration: 10, + uri: 'segment-0-uri' + }], + preloadSegment: { + parts: [ + {uri: 'part-0-uri'}, + {uri: 'part-1-uri'} + ] + } + }; + + master.playlists['playlist-0-uri'] = master.playlists[0]; + + const result = updateMaster(master, media); + + master.playlists[0].preloadSegment = media.preloadSegment; + + assert.deepEqual(result, master, 'playlist updated'); + }); + + QUnit.test('updateMaster detects preload segment addition', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }] + }] + }; + + const media = { + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + segments: [{ + duration: 10, + uri: 'segment-0-uri' + }], + preloadSegment: { + parts: [ + {uri: 'part-0-uri'}, + {uri: 'part-1-uri'} + ] + } + }; + + master.playlists['playlist-0-uri'] = master.playlists[0]; + + const result = updateMaster(master, media); + + master.playlists[0].preloadSegment = media.preloadSegment; + + assert.deepEqual(result, master, 'playlist updated'); + }); + + QUnit.test('updateMaster detects preload segment removal', function(assert) { + const master = { + playlists: [{ + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + resolvedUri: urlTo('playlist-0-uri'), + segments: [{ + duration: 10, + uri: 'segment-0-uri', + resolvedUri: urlTo('segment-0-uri') + }], + preloadSegment: { + parts: [ + {uri: 'part-0-uri'}, + {uri: 'part-1-uri'} + ] + } + }] + }; + + const media = { + mediaSequence: 0, + attributes: { + BANDWIDTH: 9 + }, + uri: 'playlist-0-uri', + id: 'playlist-0-uri', + segments: [{ + duration: 10, + uri: 'segment-1-uri', + parts: [ + {uri: 'part-0-uri'}, + {uri: 'part-1-uri'} + ] + }] + }; + + master.playlists['playlist-0-uri'] = master.playlists[0]; + + const result = updateMaster(master, media); + + master.playlists[0].preloadSegment = media.preloadSegment; + + assert.deepEqual(result, master, 'playlist updated'); + }); + QUnit.test('uses last segment duration for refresh delay', function(assert) { const media = { targetDuration: 7, segments: [] }; diff --git a/test/playlist.test.js b/test/playlist.test.js index f33de9d13..b38261734 100644 --- a/test/playlist.test.js +++ b/test/playlist.test.js @@ -282,6 +282,96 @@ QUnit.module('Playlist', function() { assert.equal(Playlist.duration(playlist, -1), 0, 'negative length duration is zero'); }); + QUnit.test('accounts for preload segment part durations', function(assert) { + const duration = Playlist.duration({ + mediaSequence: 10, + endList: true, + + segments: [{ + duration: 10, + uri: '0.ts' + }, { + duration: 10, + uri: '1.ts' + }, { + duration: 10, + uri: '2.ts' + }, { + duration: 10, + uri: '3.ts' + }, { + preload: true, + parts: [ + {duration: 2}, + {duration: 2}, + {duration: 2} + ] + }] + }); + + assert.equal(duration, 46, 'includes segments and parts'); + }); + + QUnit.test('accounts for preload segment part and preload hint durations', function(assert) { + const duration = Playlist.duration({ + mediaSequence: 10, + endList: true, + partTargetDuration: 2, + segments: [{ + duration: 10, + uri: '0.ts' + }, { + duration: 10, + uri: '1.ts' + }, { + duration: 10, + uri: '2.ts' + }, { + duration: 10, + uri: '3.ts' + }, { + preload: true, + parts: [ + {duration: 2}, + {duration: 2}, + {duration: 2} + ], + preloadHints: [ + {type: 'PART'}, + {type: 'MAP'} + ] + }] + }); + + assert.equal(duration, 48, 'includes segments, parts, and hints'); + }); + + QUnit.test('looks forward for llhls durations', function(assert) { + const playlist = { + mediaSequence: 12, + partTargetDuration: 3, + segments: [{ + duration: 10, + uri: '0.ts' + }, { + duration: 9, + uri: '1.ts' + }, { + end: 40, + preload: true, + parts: [ + {duration: 3} + ], + preloadHints: [ + {type: 'PART'} + ] + }] + }; + const duration = Playlist.duration(playlist, playlist.mediaSequence); + + assert.equal(duration, 15, 'used llhls part/preload durations'); + }); + QUnit.module('Seekable'); QUnit.test('calculates seekable time ranges from available segments', function(assert) { @@ -1613,4 +1703,51 @@ QUnit.module('Playlist', function() { }); }); + + QUnit.module('segmentDurationWithParts'); + + QUnit.test('uses normal segment duration', function(assert) { + const duration = Playlist.segmentDurationWithParts( + {}, + {duration: 5} + ); + + assert.equal(duration, 5, 'duration as expected'); + }); + + QUnit.test('preload segment without parts or preload hints', function(assert) { + const duration = Playlist.segmentDurationWithParts( + {partTargetDuration: 1}, + {preload: true} + ); + + assert.equal(duration, 0, 'duration as expected'); + }); + + QUnit.test('preload segment with parts only', function(assert) { + const duration = Playlist.segmentDurationWithParts( + {partTargetDuration: 1}, + {preload: true, parts: [{duration: 1}, {duration: 1}]} + ); + + assert.equal(duration, 2, 'duration as expected'); + }); + + QUnit.test('preload segment with preload hints only', function(assert) { + const duration = Playlist.segmentDurationWithParts( + {partTargetDuration: 1}, + {preload: true, preloadHints: [{type: 'PART'}, {type: 'PART'}, {type: 'MAP'}]} + ); + + assert.equal(duration, 2, 'duration as expected'); + }); + + QUnit.test('preload segment with preload hints and parts', function(assert) { + const duration = Playlist.segmentDurationWithParts( + {partTargetDuration: 1}, + {preload: true, parts: [{duration: 1}], preloadHints: [{type: 'PART'}, {type: 'PART'}, {type: 'MAP'}]} + ); + + assert.equal(duration, 3, 'duration as expected'); + }); }); diff --git a/test/segment-loader.test.js b/test/segment-loader.test.js index 45c1c78d4..a50dd2c8e 100644 --- a/test/segment-loader.test.js +++ b/test/segment-loader.test.js @@ -8,7 +8,8 @@ import { segmentTooLong, mediaDuration, getTroublesomeSegmentDurationMessage, - getSyncSegmentCandidate + getSyncSegmentCandidate, + segmentInfoString } from '../src/segment-loader'; import videojs from 'video.js'; import mp4probe from 'mux.js/lib/mp4/probe'; @@ -779,6 +780,251 @@ QUnit.test('info segment is bit too long', function(assert) { ); }); +QUnit.module('segmentInfoString'); + +QUnit.test('all possible information', function(assert) { + const segment = { + uri: 'foo', + parts: [ + {start: 0, end: 1, duration: 1}, + {start: 1, end: 2, duration: 1}, + {start: 2, end: 3, duration: 1}, + {start: 4, end: 5, duration: 1}, + {start: 5, end: 6, duration: 1} + ], + start: 0, + end: 6 + }; + const segmentInfo = { + startOfSegment: 1, + duration: 5, + segment, + part: segment.parts[0], + playlist: { + mediaSequence: 0, + id: 'playlist-id', + segments: [segment] + }, + mediaIndex: 0, + partIndex: 0, + timeline: 0, + independent: 'previous part', + getMediaInfoForTime: 'bufferedEnd 0' + }; + + const expected = + 'segment [0/0] ' + + 'part [0/4] ' + + 'segment start/end [0 => 6] ' + + 'part start/end [0 => 1] ' + + 'startOfSegment [1] ' + + 'duration [5] ' + + 'timeline [0] ' + + 'selected by [getMediaInfoForTime (bufferedEnd 0) with independent previous part] ' + + 'playlist [playlist-id]'; + + assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value'); +}); + +QUnit.test('mediaIndex selection', function(assert) { + const segment = { + uri: 'foo', + parts: [ + {start: 0, end: 1, duration: 1}, + {start: 1, end: 2, duration: 1}, + {start: 2, end: 3, duration: 1}, + {start: 4, end: 5, duration: 1}, + {start: 5, end: 6, duration: 1} + ], + start: 0, + end: 6 + }; + const segmentInfo = { + startOfSegment: 1, + duration: 5, + segment, + part: segment.parts[0], + playlist: { + mediaSequence: 0, + id: 'playlist-id', + segments: [segment] + }, + mediaIndex: 0, + partIndex: 0, + timeline: 0 + }; + + const expected = + 'segment [0/0] ' + + 'part [0/4] ' + + 'segment start/end [0 => 6] ' + + 'part start/end [0 => 1] ' + + 'startOfSegment [1] ' + + 'duration [5] ' + + 'timeline [0] ' + + 'selected by [mediaIndex/partIndex increment] ' + + 'playlist [playlist-id]'; + + assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value'); +}); + +QUnit.test('sync request selection', function(assert) { + const segment = { + uri: 'foo', + parts: [ + {start: 0, end: 1, duration: 1}, + {start: 1, end: 2, duration: 1}, + {start: 2, end: 3, duration: 1}, + {start: 4, end: 5, duration: 1}, + {start: 5, end: 6, duration: 1} + ], + start: 0, + end: 6 + }; + const segmentInfo = { + startOfSegment: 1, + duration: 5, + segment, + part: segment.parts[0], + playlist: { + mediaSequence: 0, + id: 'playlist-id', + segments: [segment] + }, + mediaIndex: 0, + partIndex: 0, + timeline: 0, + isSyncRequest: true + + }; + + const expected = + 'segment [0/0] ' + + 'part [0/4] ' + + 'segment start/end [0 => 6] ' + + 'part start/end [0 => 1] ' + + 'startOfSegment [1] ' + + 'duration [5] ' + + 'timeline [0] ' + + 'selected by [getSyncSegmentCandidate (isSyncRequest)] ' + + 'playlist [playlist-id]'; + + assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value'); +}); + +QUnit.test('preload segment', function(assert) { + const segment = { + parts: [ + {start: 0, end: 1, duration: 1}, + {start: 1, end: 2, duration: 1}, + {start: 2, end: 3, duration: 1}, + {start: 4, end: 5, duration: 1}, + {start: 5, end: 6, duration: 1} + ], + start: 0, + end: 6 + }; + const segmentInfo = { + startOfSegment: 1, + duration: 5, + segment, + part: segment.parts[0], + playlist: { + mediaSequence: 0, + id: 'playlist-id', + segments: [segment] + }, + mediaIndex: 0, + partIndex: 0, + timeline: 0 + }; + + const expected = + 'pre-segment [0/0] ' + + 'part [0/4] ' + + 'segment start/end [0 => 6] ' + + 'part start/end [0 => 1] ' + + 'startOfSegment [1] ' + + 'duration [5] ' + + 'timeline [0] ' + + 'selected by [mediaIndex/partIndex increment] ' + + 'playlist [playlist-id]'; + + assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value'); +}); + +QUnit.test('without parts', function(assert) { + const segment = { + start: 0, + end: 6 + }; + const segmentInfo = { + startOfSegment: 1, + duration: 5, + segment, + playlist: { + mediaSequence: 0, + id: 'playlist-id', + segments: [segment] + }, + mediaIndex: 0, + timeline: 0 + }; + + const expected = + 'pre-segment [0/0] ' + + 'segment start/end [0 => 6] ' + + 'startOfSegment [1] ' + + 'duration [5] ' + + 'timeline [0] ' + + 'selected by [mediaIndex/partIndex increment] ' + + 'playlist [playlist-id]'; + + assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value'); +}); + +QUnit.test('unknown start/end', function(assert) { + const segment = { + uri: 'foo', + parts: [ + {start: null, end: null, duration: 1}, + {start: null, end: null, duration: 1}, + {start: null, end: null, duration: 1}, + {start: null, end: null, duration: 1}, + {start: null, end: null, duration: 1} + ], + start: null, + end: null + }; + const segmentInfo = { + startOfSegment: 1, + duration: 5, + segment, + part: segment.parts[0], + playlist: { + mediaSequence: 0, + id: 'playlist-id', + segments: [segment] + }, + mediaIndex: 0, + partIndex: 0, + timeline: 0 + }; + + const expected = + 'segment [0/0] ' + + 'part [0/4] ' + + 'segment start/end [null => null] ' + + 'part start/end [null => null] ' + + 'startOfSegment [1] ' + + 'duration [5] ' + + 'timeline [0] ' + + 'selected by [mediaIndex/partIndex increment] ' + + 'playlist [playlist-id]'; + + assert.equal(segmentInfoString(segmentInfo), expected, 'expected return value'); +}); + QUnit.module('SegmentLoader', function(hooks) { hooks.beforeEach(LoaderCommonHooks.beforeEach); hooks.afterEach(LoaderCommonHooks.afterEach);