diff --git a/CHANGELOG.md b/CHANGELOG.md index aa068cfe5..428f205bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ CHANGELOG ========= ## HEAD (Unreleased) -_(none)_ +* @dmlap implement seekable for live streams. Fix in-band metadata timing for live streams. ([view](https://github.com/videojs/videojs-contrib-hls/pull/295)) -------------------- diff --git a/Gruntfile.js b/Gruntfile.js index f4e630045..f02910006 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -34,6 +34,7 @@ module.exports = function(grunt) { 'src/segment-parser.js', 'src/m3u8/m3u8-parser.js', 'src/xhr.js', + 'src/playlist.js', 'src/playlist-loader.js', 'node_modules/pkcs7/dist/pkcs7.unpad.js', 'src/decrypter.js' diff --git a/example.html b/example.html index 1d938d3b7..18b252ff9 100644 --- a/example.html +++ b/example.html @@ -28,13 +28,14 @@ + - + diff --git a/src/aac-stream.js b/src/aac-stream.js index 5bba5aac0..0285ecd0c 100644 --- a/src/aac-stream.js +++ b/src/aac-stream.js @@ -12,7 +12,6 @@ var window.videojs.Hls.AacStream = function() { var next_pts, // :uint - pts_offset, // :int state, // :uint pes_length, // :int lastMetaPts, @@ -32,7 +31,6 @@ window.videojs.Hls.AacStream = function() { // (pts:uint):void this.setTimeStampOffset = function(pts) { - pts_offset = pts; // keep track of the last time a metadata tag was written out // set the initial value so metadata will be generated before any @@ -42,7 +40,7 @@ window.videojs.Hls.AacStream = function() { // (pts:uint, pes_size:int, dataAligned:Boolean):void this.setNextTimeStamp = function(pts, pes_size, dataAligned) { - next_pts = pts - pts_offset; + next_pts = pts; pes_length = pes_size; // If data is aligned, flush all internal buffers diff --git a/src/h264-stream.js b/src/h264-stream.js index a9e1b4427..dff147d6c 100644 --- a/src/h264-stream.js +++ b/src/h264-stream.js @@ -37,7 +37,6 @@ window.videojs.Hls.H264Stream = H264Stream = function() { this._next_pts = 0; // :uint; this._next_dts = 0; // :uint; - this._pts_offset = 0; // :int this._h264Frame = null; // :FlvTag @@ -52,15 +51,13 @@ }; //(pts:uint):void - H264Stream.prototype.setTimeStampOffset = function(pts) { - this._pts_offset = pts; - }; + H264Stream.prototype.setTimeStampOffset = function() {}; //(pts:uint, dts:uint, dataAligned:Boolean):void H264Stream.prototype.setNextTimeStamp = function(pts, dts, dataAligned) { // We could end up with a DTS less than 0 here. We need to deal with that! - this._next_pts = pts - this._pts_offset; - this._next_dts = dts - this._pts_offset; + this._next_pts = pts; + this._next_dts = dts; // If data is aligned, flush all internal buffers if (dataAligned) { diff --git a/src/m3u8/m3u8-parser.js b/src/m3u8/m3u8-parser.js index 89ffb5b9d..2eb537c2b 100644 --- a/src/m3u8/m3u8-parser.js +++ b/src/m3u8/m3u8-parser.js @@ -210,6 +210,18 @@ this.trigger('data', event); return; } + match = (/^#EXT-X-DISCONTINUITY-SEQUENCE:?(\-?[0-9.]*)?/).exec(line); + if (match) { + event = { + type: 'tag', + tagType: 'discontinuity-sequence' + }; + if (match[1]) { + event.number = parseInt(match[1], 10); + } + this.trigger('data', event); + return; + } match = (/^#EXT-X-PLAYLIST-TYPE:?(.*)?$/).exec(line); if (match) { event = { @@ -308,7 +320,7 @@ event.attributes = parseAttributes(match[1]); // parse the IV string into a Uint32Array if (event.attributes.IV) { - if (event.attributes.IV.substring(0,2) === '0x') { + if (event.attributes.IV.substring(0,2) === '0x') { event.attributes.IV = event.attributes.IV.substring(2); } @@ -409,6 +421,12 @@ message: 'defaulting media sequence to zero' }); } + if (!('discontinuitySequence' in this.manifest)) { + this.manifest.discontinuitySequence = 0; + this.trigger('info', { + message: 'defaulting discontinuity sequence to zero' + }); + } if (entry.duration >= 0) { currentUri.duration = entry.duration; } @@ -459,6 +477,15 @@ } this.manifest.mediaSequence = entry.number; }, + 'discontinuity-sequence': function() { + if (!isFinite(entry.number)) { + this.trigger('warn', { + message: 'ignoring invalid discontinuity sequence: ' + entry.number + }); + return; + } + this.manifest.discontinuitySequence = entry.number; + }, 'playlist-type': function() { if (!(/VOD|EVENT/).test(entry.playlistType)) { this.trigger('warn', { diff --git a/src/metadata-stream.js b/src/metadata-stream.js index 61afaacef..c8757bd65 100644 --- a/src/metadata-stream.js +++ b/src/metadata-stream.js @@ -37,7 +37,8 @@ if (tag.data[i] === 0) { // parse the text fields tag.description = parseUtf8(tag.data, 1, i); - tag.value = parseUtf8(tag.data, i + 1, tag.data.length); + // do not include the null terminator in the tag value + tag.value = parseUtf8(tag.data, i + 1, tag.data.length - 1); break; } } @@ -173,13 +174,6 @@ (tag.data[19]); } - // adjust the PTS values to align with the video and audio - // streams - if (this.timestampOffset) { - tag.pts -= this.timestampOffset; - tag.dts -= this.timestampOffset; - } - // parse one or more ID3 frames // http://id3.org/id3v2.3.0#ID3v2_frame_overview do { diff --git a/src/playlist-loader.js b/src/playlist-loader.js index 7446a1a8e..0d6ae1090 100644 --- a/src/playlist-loader.js +++ b/src/playlist-loader.js @@ -1,12 +1,23 @@ /** * A state machine that manages the loading, caching, and updating of - * M3U8 playlists. + * M3U8 playlists. When tracking a live playlist, loaders will keep + * track of the duration of content that expired since the loader was + * initialized and when the current discontinuity sequence was + * encountered. A complete media timeline for a live playlist with + * expiring segments and discontinuities looks like this: + * + * |-- expiredPreDiscontinuity --|-- expiredPostDiscontinuity --|-- segments --| + * + * You can use these values to calculate how much time has elapsed + * since the stream began loading or how long it has been since the + * most recent discontinuity was encountered, for instance. */ (function(window, videojs) { 'use strict'; var resolveUrl = videojs.Hls.resolveUrl, xhr = videojs.Hls.xhr, + Playlist = videojs.Hls.Playlist, /** * Returns a new master playlist that is the result of merging an @@ -51,66 +62,84 @@ var loader = this, dispose, - media, mediaUpdateTimeout, request, + haveMetadata; - haveMetadata = function(error, xhr, url) { - var parser, refreshDelay, update; + PlaylistLoader.prototype.init.call(this); - loader.setBandwidth(request || xhr); + if (!srcUrl) { + throw new Error('A non-empty playlist URL is required'); + } - // any in-flight request is now finished - request = null; + // update the playlist loader's state in response to a new or + // updated playlist. + haveMetadata = function(error, xhr, url) { + var parser, refreshDelay, update; - if (error) { - loader.error = { - status: xhr.status, - message: 'HLS playlist request error at URL: ' + url, - responseText: xhr.responseText, - code: (xhr.status >= 500) ? 4 : 2 - }; - return loader.trigger('error'); - } + loader.setBandwidth(request || xhr); - loader.state = 'HAVE_METADATA'; + // any in-flight request is now finished + request = null; - parser = new videojs.m3u8.Parser(); - parser.push(xhr.responseText); - parser.end(); - parser.manifest.uri = url; - - // merge this playlist into the master - update = updateMaster(loader.master, parser.manifest); - refreshDelay = (parser.manifest.targetDuration || 10) * 1000; - if (update) { - loader.master = update; - media = loader.master.playlists[url]; - } else { - // if the playlist is unchanged since the last reload, - // try again after half the target duration - refreshDelay /= 2; - } + if (error) { + loader.error = { + status: xhr.status, + message: 'HLS playlist request error at URL: ' + url, + responseText: xhr.responseText, + code: (xhr.status >= 500) ? 4 : 2 + }; + return loader.trigger('error'); + } - // refresh live playlists after a target duration passes - if (!loader.media().endList) { - window.clearTimeout(mediaUpdateTimeout); - mediaUpdateTimeout = window.setTimeout(function() { - loader.trigger('mediaupdatetimeout'); - }, refreshDelay); - } + loader.state = 'HAVE_METADATA'; - loader.trigger('loadedplaylist'); - }; + parser = new videojs.m3u8.Parser(); + parser.push(xhr.responseText); + parser.end(); + parser.manifest.uri = url; + + // merge this playlist into the master + update = updateMaster(loader.master, parser.manifest); + refreshDelay = (parser.manifest.targetDuration || 10) * 1000; + if (update) { + loader.master = update; + loader.updateMediaPlaylist_(parser.manifest); + } else { + // if the playlist is unchanged since the last reload, + // try again after half the target duration + refreshDelay /= 2; + } - PlaylistLoader.prototype.init.call(this); + // refresh live playlists after a target duration passes + if (!loader.media().endList) { + window.clearTimeout(mediaUpdateTimeout); + mediaUpdateTimeout = window.setTimeout(function() { + loader.trigger('mediaupdatetimeout'); + }, refreshDelay); + } - if (!srcUrl) { - throw new Error('A non-empty playlist URL is required'); - } + loader.trigger('loadedplaylist'); + }; + // initialize the loader state loader.state = 'HAVE_NOTHING'; + // the total duration of all segments that expired and have been + // removed from the current playlist after the last + // #EXT-X-DISCONTINUITY. In a live playlist without + // discontinuities, this is the total amount of time that has + // been removed from the stream since the playlist loader began + // tracking it. + loader.expiredPostDiscontinuity_ = 0; + + // the total duration of all segments that expired and have been + // removed from the current playlist before the last + // #EXT-X-DISCONTINUITY. The total amount of time that has + // expired is always the sum of expiredPreDiscontinuity_ and + // expiredPostDiscontinuity_. + loader.expiredPreDiscontinuity_ = 0; + // capture the prototype dispose function dispose = this.dispose; @@ -141,7 +170,7 @@ var mediaChange = false; // getter if (!playlist) { - return media; + return loader.media_; } // setter @@ -158,7 +187,7 @@ playlist = loader.master.playlists[playlist]; } - mediaChange = playlist.uri !== media.uri; + mediaChange = playlist.uri !== loader.media_.uri; // switch to fully loaded playlists immediately if (loader.master.playlists[playlist.uri].endList) { @@ -169,7 +198,7 @@ request = null; } loader.state = 'HAVE_METADATA'; - media = playlist; + loader.media_ = playlist; // trigger media change if the active media has been updated if (mediaChange) { @@ -292,5 +321,46 @@ }; PlaylistLoader.prototype = new videojs.Hls.Stream(); + /** + * Update the PlaylistLoader state to reflect the changes in an + * update to the current media playlist. + * @param update {object} the updated media playlist object + */ + PlaylistLoader.prototype.updateMediaPlaylist_ = function(update) { + var lastDiscontinuity, expiredCount, i; + + if (this.media_) { + expiredCount = update.mediaSequence - this.media_.mediaSequence; + + // setup the index for duration calculations so that the newly + // expired time will be accumulated after the last + // discontinuity, unless we discover otherwise + lastDiscontinuity = this.media_.mediaSequence; + + if (this.media_.discontinuitySequence !== update.discontinuitySequence) { + i = expiredCount; + while (i--) { + if (this.media_.segments[i].discontinuity) { + // a segment that begins a new discontinuity sequence has expired + lastDiscontinuity = i + this.media_.mediaSequence; + this.expiredPreDiscontinuity_ += this.expiredPostDiscontinuity_; + this.expiredPostDiscontinuity_ = 0; + break; + } + } + } + + // update the expirated durations + this.expiredPreDiscontinuity_ += Playlist.duration(this.media_, + this.media_.mediaSequence, + lastDiscontinuity); + this.expiredPostDiscontinuity_ += Playlist.duration(this.media_, + lastDiscontinuity, + this.media_.mediaSequence + expiredCount); + } + + this.media_ = this.master.playlists[update.uri]; + }; + videojs.Hls.PlaylistLoader = PlaylistLoader; })(window, window.videojs); diff --git a/src/playlist.js b/src/playlist.js new file mode 100644 index 000000000..7bce582d7 --- /dev/null +++ b/src/playlist.js @@ -0,0 +1,139 @@ +/** + * Playlist related utilities. + */ +(function(window, videojs) { + 'use strict'; + + var DEFAULT_TARGET_DURATION = 10; + var duration, seekable, segmentsDuration; + + /** + * Calculate the media duration from the segments associated with a + * playlist. The duration of a subinterval of the available segments + * may be calculated by specifying a start and end index. + * + * @param playlist {object} a media playlist object + * @param startSequence {number} (optional) an inclusive lower + * boundary for the playlist. Defaults to 0. + * @param endSequence {number} (optional) an exclusive upper boundary + * for the playlist. Defaults to playlist length. + * @return {number} the duration between the start index and end + * index. + */ + segmentsDuration = function(playlist, startSequence, endSequence) { + var targetDuration, i, segment, expiredSegmentCount, result = 0; + + startSequence = startSequence || 0; + i = startSequence; + endSequence = endSequence !== undefined ? endSequence : (playlist.segments || []).length; + targetDuration = playlist.targetDuration || DEFAULT_TARGET_DURATION; + + // estimate expired segment duration using the target duration + expiredSegmentCount = Math.max(playlist.mediaSequence - startSequence, 0); + result += expiredSegmentCount * targetDuration; + i += expiredSegmentCount; + + // accumulate the segment durations into the result + for (; i < endSequence; i++) { + segment = playlist.segments[i - playlist.mediaSequence]; + result += segment.preciseDuration || + segment.duration || + targetDuration; + } + + return result; + }; + + /** + * Calculates the duration of a playlist. If a start and end index + * are specified, the duration will be for the subset of the media + * timeline between those two indices. The total duration for live + * playlists is always Infinity. + * @param playlist {object} a media playlist object + * @param startSequence {number} (optional) an inclusive lower + * boundary for the playlist. Defaults to 0. + * @param endSequence {number} (optional) an exclusive upper boundary + * for the playlist. Defaults to playlist length. + * @return {number} the duration between the start index and end + * index. + */ + duration = function(playlist, startSequence, endSequence) { + if (!playlist) { + return 0; + } + + // if a slice of the total duration is not requested, use + // playlist-level duration indicators when they're present + if (startSequence === undefined && endSequence === undefined) { + // if present, use the duration specified in the playlist + if (playlist.totalDuration) { + return playlist.totalDuration; + } + + // duration should be Infinity for live playlists + if (!playlist.endList) { + return window.Infinity; + } + } + + // calculate the total duration based on the segment durations + return segmentsDuration(playlist, + startSequence, + endSequence); + }; + + /** + * Calculates the interval of time that is currently seekable in a + * playlist. + * @param playlist {object} a media playlist object + * @return {TimeRanges} the periods of time that are valid targets + * for seeking + */ + seekable = function(playlist) { + var start, end, liveBuffer, targetDuration, segment, pending, i; + + // without segments, there are no seekable ranges + if (!playlist.segments) { + return videojs.createTimeRange(); + } + // when the playlist is complete, the entire duration is seekable + if (playlist.endList) { + return videojs.createTimeRange(0, duration(playlist)); + } + + start = segmentsDuration(playlist, 0, playlist.mediaSequence); + end = start + segmentsDuration(playlist, + playlist.mediaSequence, + playlist.mediaSequence + playlist.segments.length); + targetDuration = playlist.targetDuration || DEFAULT_TARGET_DURATION; + + // live playlists should not expose three segment durations worth + // of content from the end of the playlist + // https://tools.ietf.org/html/draft-pantos-http-live-streaming-16#section-6.3.3 + if (!playlist.endList) { + liveBuffer = targetDuration * 3; + // walk backward from the last available segment and track how + // much media time has elapsed until three target durations have + // been traversed. if a segment is part of the interval being + // reported, subtract the overlapping portion of its duration + // from the result. + for (i = playlist.segments.length - 1; i >= 0 && liveBuffer > 0; i--) { + segment = playlist.segments[i]; + pending = Math.min(segment.preciseDuration || + segment.duration || + targetDuration, + liveBuffer); + liveBuffer -= pending; + end -= pending; + } + } + + return videojs.createTimeRange(start, end); + }; + + // exports + videojs.Hls.Playlist = { + duration: duration, + seekable: seekable + }; +})(window, window.videojs); diff --git a/src/segment-parser.js b/src/segment-parser.js index f07362454..00189f262 100644 --- a/src/segment-parser.js +++ b/src/segment-parser.js @@ -19,10 +19,7 @@ streamBuffer = new Uint8Array(MP2T_PACKET_LENGTH), streamBufferByteCount = 0, h264Stream = new H264Stream(), - aacStream = new AacStream(), - h264HasTimeStampOffset = false, - aacHasTimeStampOffset = false, - timeStampOffset; + aacStream = new AacStream(); // expose the stream metadata self.stream = { @@ -34,6 +31,15 @@ // allow in-band metadata to be observed self.metadataStream = new MetadataStream(); + this.mediaTimelineOffset = null; + + // The first timestamp value encountered during parsing. This + // value can be used to determine the relative timing between + // frames and the start of the current timestamp sequence. It + // should be reset to null before parsing a segment with + // discontinuous timestamp values from previous segments. + self.timestampOffset = null; + // For information on the FLV format, see // http://download.macromedia.com/f4v/video_file_format_spec_v10_1.pdf. // Technically, this function returns the header and a metadata FLV tag @@ -354,31 +360,18 @@ // Skip past "optional" portion of PTS header offset += pesHeaderLength; - // align the metadata stream PTS values with the start of - // the other elementary streams - if (!self.metadataStream.timestampOffset) { - self.metadataStream.timestampOffset = pts; + // keep track of the earliest encounted PTS value so + // external parties can align timestamps across + // discontinuities + if (self.timestampOffset === null) { + self.timestampOffset = pts; } if (pid === self.stream.programMapTable[STREAM_TYPES.h264]) { - if (!h264HasTimeStampOffset) { - h264HasTimeStampOffset = true; - if (timeStampOffset === undefined) { - timeStampOffset = pts; - } - h264Stream.setTimeStampOffset(timeStampOffset); - } h264Stream.setNextTimeStamp(pts, dts, dataAlignmentIndicator); } else if (pid === self.stream.programMapTable[STREAM_TYPES.adts]) { - if (!aacHasTimeStampOffset) { - aacHasTimeStampOffset = true; - if (timeStampOffset === undefined) { - timeStampOffset = pts; - } - aacStream.setTimeStampOffset(timeStampOffset); - } aacStream.setNextTimeStamp(pts, pesPacketSize, dataAlignmentIndicator); diff --git a/src/videojs-hls.js b/src/videojs-hls.js index e7165369b..b9ef42883 100644 --- a/src/videojs-hls.js +++ b/src/videojs-hls.js @@ -39,9 +39,11 @@ videojs.Hls = videojs.Flash.extend({ this.currentTime = videojs.Hls.prototype.currentTime; this.setCurrentTime = videojs.Hls.prototype.setCurrentTime; + // a queue of segments that need to be transmuxed and processed, + // and then fed to the source buffer + this.segmentBuffer_ = []; // periodically check if new data needs to be downloaded or // buffered data should be appended to the source buffer - this.segmentBuffer_ = []; this.startCheckingBuffer_(); videojs.Hls.prototype.src.call(this, options.source && options.source.src); @@ -87,43 +89,7 @@ videojs.Hls.prototype.src = function(src) { // if the stream contains ID3 metadata, expose that as a metadata // text track - (function() { - var - metadataStream = tech.segmentParser_.metadataStream, - textTrack; - - // only expose metadata tracks to video.js versions that support - // dynamic text tracks (4.12+) - if (!tech.player().addTextTrack) { - return; - } - - metadataStream.on('data', function(metadata) { - var i, cue, frame, time, hexDigit; - - // create the metadata track if this is the first ID3 tag we've - // seen - if (!textTrack) { - textTrack = tech.player().addTextTrack('metadata', 'Timed Metadata'); - - // build the dispatch type from the stream descriptor - // https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track - textTrack.inBandMetadataTrackDispatchType = videojs.Hls.SegmentParser.STREAM_TYPES.metadata.toString(16).toUpperCase(); - for (i = 0; i < metadataStream.descriptor.length; i++) { - hexDigit = ('00' + metadataStream.descriptor[i].toString(16).toUpperCase()).slice(-2); - textTrack.inBandMetadataTrackDispatchType += hexDigit; - } - } - - for (i = 0; i < metadata.frames.length; i++) { - frame = metadata.frames[i]; - time = metadata.pts / 1000; - cue = new window.VTTCue(time, time, frame.value || frame.url || ''); - cue.frame = frame; - textTrack.addCue(cue); - } - }); - })(); + this.setupMetadataCueTranslation_(); // load the MediaSource into the player this.mediaSource.addEventListener('sourceopen', videojs.bind(this, this.handleSourceOpen)); @@ -280,6 +246,75 @@ videojs.Hls.prototype.handleSourceOpen = function() { } }; +// register event listeners to transform in-band metadata events into +// VTTCues on a text track +videojs.Hls.prototype.setupMetadataCueTranslation_ = function() { + var + tech = this, + metadataStream = tech.segmentParser_.metadataStream, + textTrack; + + // only expose metadata tracks to video.js versions that support + // dynamic text tracks (4.12+) + if (!tech.player().addTextTrack) { + return; + } + + // add a metadata cue whenever a metadata event is triggered during + // segment parsing + metadataStream.on('data', function(metadata) { + var i, cue, frame, time, media, segmentOffset, hexDigit; + + // create the metadata track if this is the first ID3 tag we've + // seen + if (!textTrack) { + textTrack = tech.player().addTextTrack('metadata', 'Timed Metadata'); + + // build the dispatch type from the stream descriptor + // https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track + textTrack.inBandMetadataTrackDispatchType = videojs.Hls.SegmentParser.STREAM_TYPES.metadata.toString(16).toUpperCase(); + for (i = 0; i < metadataStream.descriptor.length; i++) { + hexDigit = ('00' + metadataStream.descriptor[i].toString(16).toUpperCase()).slice(-2); + textTrack.inBandMetadataTrackDispatchType += hexDigit; + } + } + + // calculate the start time for the segment that is currently being parsed + media = tech.playlists.media(); + segmentOffset = tech.playlists.expiredPreDiscontinuity_ + tech.playlists.expiredPostDiscontinuity_; + segmentOffset += videojs.Hls.Playlist.duration(media, media.mediaSequence, media.mediaSequence + tech.mediaIndex); + + // create cue points for all the ID3 frames in this metadata event + for (i = 0; i < metadata.frames.length; i++) { + frame = metadata.frames[i]; + time = tech.segmentParser_.mediaTimelineOffset + ((metadata.pts - tech.segmentParser_.timestampOffset) * 0.001); + cue = new window.VTTCue(time, time, frame.value || frame.url || ''); + cue.frame = frame; + textTrack.addCue(cue); + } + }); + + // when seeking, clear out all cues ahead of the earliest position + // in the new segment. keep earlier cues around so they can still be + // programmatically inspected even though they've already fired + tech.on(tech.player(), 'seeking', function() { + var media, startTime, i; + if (!textTrack) { + return; + } + media = tech.playlists.media(); + startTime = tech.playlists.expiredPreDiscontinuity_ + tech.playlists.expiredPostDiscontinuity_; + startTime += videojs.Hls.Playlist.duration(media, media.mediaSequence, media.mediaSequence + tech.mediaIndex); + + i = textTrack.cues.length; + while (i--) { + if (textTrack.cues[i].startTime >= startTime) { + textTrack.removeCue(textTrack.cues[i]); + } + } + }); +}; + /** * Reset the mediaIndex if play() is called after the video has * ended. @@ -350,18 +385,37 @@ videojs.Hls.prototype.setCurrentTime = function(currentTime) { videojs.Hls.prototype.duration = function() { var playlists = this.playlists; if (playlists) { - return videojs.Hls.getPlaylistTotalDuration(playlists.media()); + return videojs.Hls.Playlist.duration(playlists.media()); } return 0; }; +videojs.Hls.prototype.seekable = function() { + var absoluteSeekable, startOffset, media; + + if (!this.playlists) { + return videojs.createTimeRange(); + } + media = this.playlists.media(); + if (!media) { + return videojs.createTimeRange(); + } + + // report the seekable range relative to the earliest possible + // position when the stream was first loaded + absoluteSeekable = videojs.Hls.Playlist.seekable(media); + startOffset = this.playlists.expiredPostDiscontinuity_ - this.playlists.expiredPreDiscontinuity_; + return videojs.createTimeRange(startOffset, + startOffset + (absoluteSeekable.end(0) - absoluteSeekable.start(0))); +}; + /** * Update the player duration */ videojs.Hls.prototype.updateDuration = function(playlist) { var player = this.player(), oldDuration = player.duration(), - newDuration = videojs.Hls.getPlaylistTotalDuration(playlist); + newDuration = videojs.Hls.Playlist.duration(playlist); // if the duration has changed, invalidate the cached value if (oldDuration !== newDuration) { @@ -684,9 +738,6 @@ videojs.Hls.prototype.loadSegment = function(segmentUri, offset) { tech.setBandwidth(this); // package up all the work to append the segment - // if the segment is the start of a timestamp discontinuity, - // we have to wait until the sourcebuffer is empty before - // aborting the source buffer processing segmentInfo = { // the segment's mediaIndex at the time it was received mediaIndex: tech.mediaIndex, @@ -789,7 +840,20 @@ videojs.Hls.prototype.drainBuffer = function(event) { } event = event || {}; - segmentOffset = videojs.Hls.getPlaylistDuration(playlist, 0, mediaIndex) * 1000; + segmentOffset = this.playlists.expiredPreDiscontinuity_; + segmentOffset += this.playlists.expiredPostDiscontinuity_; + segmentOffset += videojs.Hls.Playlist.duration(playlist, playlist.mediaSequence, playlist.mediaSequence + mediaIndex); + segmentOffset *= 1000; + + // if this segment starts is the start of a new discontinuity + // sequence, the segment parser's timestamp offset must be + // re-calculated + if (segment.discontinuity) { + this.segmentParser_.mediaTimelineOffset = segmentOffset * 0.001; + this.segmentParser_.timestampOffset = null; + } else if (this.segmentParser_.mediaTimelineOffset === null) { + this.segmentParser_.mediaTimelineOffset = segmentOffset * 0.001; + } // transmux the segment data from MP2T to FLV this.segmentParser_.parseSegmentBinaryData(bytes); @@ -801,10 +865,10 @@ videojs.Hls.prototype.drainBuffer = function(event) { tags.push(this.segmentParser_.getNextTag()); } - // Use the presentation timestamp of the ts segment to calculate its - // exact duration, since this may differ by fractions of a second - // from what is reported in the playlist if (tags.length > 0) { + // Use the presentation timestamp of the ts segment to calculate its + // exact duration, since this may differ by fractions of a second + // from what is reported in the playlist segment.preciseDuration = videojs.Hls.FlvTag.durationFromTags(tags) * 0.001; } @@ -976,20 +1040,9 @@ videojs.Hls.canPlaySource = function(srcObj) { * @return {number} the duration between the start index and end index. */ videojs.Hls.getPlaylistDuration = function(playlist, startIndex, endIndex) { - var dur = 0, - segment, - i; - - startIndex = startIndex || 0; - endIndex = endIndex !== undefined ? endIndex : (playlist.segments || []).length; - i = endIndex - 1; - - for (; i >= startIndex; i--) { - segment = playlist.segments[i]; - dur += segment.preciseDuration || segment.duration || playlist.targetDuration || 0; - } - - return dur; + videojs.log.warn('videojs.Hls.getPlaylistDuration is deprecated. ' + + 'Use videojs.Hls.Playlist.duration instead'); + return videojs.Hls.Playlist.duration(playlist, startIndex, endIndex); }; /** @@ -998,21 +1051,9 @@ videojs.Hls.getPlaylistDuration = function(playlist, startIndex, endIndex) { * @return {number} the currently known duration, in seconds */ videojs.Hls.getPlaylistTotalDuration = function(playlist) { - if (!playlist) { - return 0; - } - - // if present, use the duration specified in the playlist - if (playlist.totalDuration) { - return playlist.totalDuration; - } - - // duration should be Infinity for live playlists - if (!playlist.endList) { - return window.Infinity; - } - - return videojs.Hls.getPlaylistDuration(playlist); + videojs.log.warn('videojs.Hls.getPlaylistTotalDuration is deprecated. ' + + 'Use videojs.Hls.Playlist.duration instead'); + return videojs.Hls.Playlist.duration(playlist); }; /** diff --git a/test/h264-stream_test.js b/test/h264-stream_test.js index d36fb4489..7ea263f09 100644 --- a/test/h264-stream_test.js +++ b/test/h264-stream_test.js @@ -61,45 +61,6 @@ test('metadata is generated for IDRs after a full NAL unit is written', function ok(h264Stream.tags[2].keyFrame, 'key frame is written'); }); -test('starting PTS values can be negative', function() { - var - H264ExtraData = videojs.Hls.H264ExtraData, - oldExtraData = H264ExtraData.prototype.extraDataTag, - oldMetadata = H264ExtraData.prototype.metaDataTag, - h264Stream; - - H264ExtraData.prototype.extraDataTag = function() { - return 'extraDataTag'; - }; - H264ExtraData.prototype.metaDataTag = function() { - return 'metaDataTag'; - }; - - h264Stream = new videojs.Hls.H264Stream(); - - h264Stream.setTimeStampOffset(-100); - h264Stream.setNextTimeStamp(-100, -100, true); - h264Stream.writeBytes(accessUnitDelimiter, 0, accessUnitDelimiter.byteLength); - h264Stream.setNextTimeStamp(-99, -99, true); - h264Stream.writeBytes(accessUnitDelimiter, 0, accessUnitDelimiter.byteLength); - h264Stream.setNextTimeStamp(0, 0, true); - h264Stream.writeBytes(accessUnitDelimiter, 0, accessUnitDelimiter.byteLength); - // flush out the last tag - h264Stream.writeBytes(accessUnitDelimiter, 0, accessUnitDelimiter.byteLength); - - strictEqual(h264Stream.tags.length, 3, 'three tags are ready'); - strictEqual(h264Stream.tags[0].pts, 0, 'the first PTS is zero'); - strictEqual(h264Stream.tags[0].dts, 0, 'the first DTS is zero'); - strictEqual(h264Stream.tags[1].pts, 1, 'the second PTS is one'); - strictEqual(h264Stream.tags[1].dts, 1, 'the second DTS is one'); - - strictEqual(h264Stream.tags[2].pts, 100, 'the third PTS is 100'); - strictEqual(h264Stream.tags[2].dts, 100, 'the third DTS is 100'); - - H264ExtraData.prototype.extraDataTag = oldExtraData; - H264ExtraData.prototype.metaDataTag = oldMetadata; -}); - test('make sure we add metadata and extra data at the beginning of a stream', function() { var H264ExtraData = videojs.Hls.H264ExtraData, diff --git a/test/karma.conf.js b/test/karma.conf.js index 6fb6d66c4..841204a8f 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -75,7 +75,7 @@ module.exports = function(config) { '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', '../node_modules/sinon/lib/sinon/util/xhr_ie.js', '../node_modules/sinon/lib/sinon/util/fake_timers.js', - '../node_modules/video.js/dist/video-js/video.js', + '../node_modules/video.js/dist/video-js/video.dev.js', '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', '../node_modules/pkcs7/dist/pkcs7.unpad.js', '../test/karma-qunit-shim.js', @@ -90,6 +90,7 @@ module.exports = function(config) { '../src/segment-parser.js', '../src/m3u8/m3u8-parser.js', '../src/xhr.js', + '../src/playlist.js', '../src/playlist-loader.js', '../src/decrypter.js', '../tmp/manifests.js', diff --git a/test/localkarma.conf.js b/test/localkarma.conf.js index 304610421..2d08cf5d4 100644 --- a/test/localkarma.conf.js +++ b/test/localkarma.conf.js @@ -39,7 +39,7 @@ module.exports = function(config) { '../node_modules/sinon/lib/sinon/util/fake_xml_http_request.js', '../node_modules/sinon/lib/sinon/util/xhr_ie.js', '../node_modules/sinon/lib/sinon/util/fake_timers.js', - '../node_modules/video.js/dist/video-js/video.js', + '../node_modules/video.js/dist/video-js/video.dev.js', '../node_modules/videojs-contrib-media-sources/src/videojs-media-sources.js', '../node_modules/pkcs7/dist/pkcs7.unpad.js', '../test/karma-qunit-shim.js', @@ -54,6 +54,7 @@ module.exports = function(config) { '../src/segment-parser.js', '../src/m3u8/m3u8-parser.js', '../src/xhr.js', + '../src/playlist.js', '../src/playlist-loader.js', '../src/decrypter.js', '../tmp/manifests.js', diff --git a/test/manifest/absoluteUris.js b/test/manifest/absoluteUris.js index 68dd2ea56..208de13f4 100644 --- a/test/manifest/absoluteUris.js +++ b/test/manifest/absoluteUris.js @@ -21,5 +21,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/allowCache.js b/test/manifest/allowCache.js index 629c49a38..58e49317a 100644 --- a/test/manifest/allowCache.js +++ b/test/manifest/allowCache.js @@ -141,5 +141,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/allowCacheInvalid.js b/test/manifest/allowCacheInvalid.js index 12136cd64..b721a9c8c 100644 --- a/test/manifest/allowCacheInvalid.js +++ b/test/manifest/allowCacheInvalid.js @@ -13,5 +13,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/byteRange.js b/test/manifest/byteRange.js index 6b0f450af..324e829e0 100644 --- a/test/manifest/byteRange.js +++ b/test/manifest/byteRange.js @@ -137,5 +137,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/disallowCache.js b/test/manifest/disallowCache.js index 0d6617f6c..a297080ff 100644 --- a/test/manifest/disallowCache.js +++ b/test/manifest/disallowCache.js @@ -13,5 +13,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/disc-sequence.js b/test/manifest/disc-sequence.js new file mode 100644 index 000000000..4c84a97bb --- /dev/null +++ b/test/manifest/disc-sequence.js @@ -0,0 +1,26 @@ +{ + "allowCache": true, + "mediaSequence": 0, + "discontinuitySequence": 3, + "segments": [ + { + "duration": 10, + "uri": "001.ts" + }, + { + "duration": 19, + "uri": "002.ts" + }, + { + "discontinuity": true, + "duration": 10, + "uri": "003.ts" + }, + { + "duration": 11, + "uri": "004.ts" + } + ], + "targetDuration": 19, + "endList": true +} diff --git a/test/manifest/disc-sequence.m3u8 b/test/manifest/disc-sequence.m3u8 new file mode 100644 index 000000000..5ac89ba48 --- /dev/null +++ b/test/manifest/disc-sequence.m3u8 @@ -0,0 +1,15 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:19 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-DISCONTINUITY-SEQUENCE:3 +#EXTINF:10,0 +001.ts +#EXTINF:19,0 +002.ts +#EXT-X-DISCONTINUITY +#EXTINF:10,0 +003.ts +#EXTINF:11,0 +004.ts +#EXT-X-ENDLIST diff --git a/test/manifest/discontinuity.js b/test/manifest/discontinuity.js index bf0e07627..8b9d874df 100644 --- a/test/manifest/discontinuity.js +++ b/test/manifest/discontinuity.js @@ -1,6 +1,7 @@ { "allowCache": true, "mediaSequence": 0, + "discontinuitySequence": 0, "segments": [ { "duration": 10, diff --git a/test/manifest/domainUris.js b/test/manifest/domainUris.js index 25de9ae5f..fc9504696 100644 --- a/test/manifest/domainUris.js +++ b/test/manifest/domainUris.js @@ -21,5 +21,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/emptyAllowCache.js b/test/manifest/emptyAllowCache.js index 12136cd64..b721a9c8c 100644 --- a/test/manifest/emptyAllowCache.js +++ b/test/manifest/emptyAllowCache.js @@ -13,5 +13,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/emptyMediaSequence.js b/test/manifest/emptyMediaSequence.js index be7fa289a..b5f53cd01 100644 --- a/test/manifest/emptyMediaSequence.js +++ b/test/manifest/emptyMediaSequence.js @@ -21,5 +21,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/emptyPlaylistType.js b/test/manifest/emptyPlaylistType.js index 1590d6827..fc4c2aec5 100644 --- a/test/manifest/emptyPlaylistType.js +++ b/test/manifest/emptyPlaylistType.js @@ -28,5 +28,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/encrypted.js b/test/manifest/encrypted.js index d6f296e0d..80ad8eaa8 100644 --- a/test/manifest/encrypted.js +++ b/test/manifest/encrypted.js @@ -1,6 +1,7 @@ { "allowCache": true, "mediaSequence": 7794, + "discontinuitySequence": 0, "segments": [ { "duration": 2.833, diff --git a/test/manifest/event.js b/test/manifest/event.js index 8727ff064..9ec9248b6 100644 --- a/test/manifest/event.js +++ b/test/manifest/event.js @@ -29,5 +29,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/extXPlaylistTypeInvalidPlaylist.js b/test/manifest/extXPlaylistTypeInvalidPlaylist.js index 94030aad8..760713d15 100644 --- a/test/manifest/extXPlaylistTypeInvalidPlaylist.js +++ b/test/manifest/extXPlaylistTypeInvalidPlaylist.js @@ -8,5 +8,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/extinf.js b/test/manifest/extinf.js index cdbd5346e..be5f64887 100644 --- a/test/manifest/extinf.js +++ b/test/manifest/extinf.js @@ -141,5 +141,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/invalidAllowCache.js b/test/manifest/invalidAllowCache.js index 12136cd64..b721a9c8c 100644 --- a/test/manifest/invalidAllowCache.js +++ b/test/manifest/invalidAllowCache.js @@ -13,5 +13,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/invalidMediaSequence.js b/test/manifest/invalidMediaSequence.js index be7fa289a..b5f53cd01 100644 --- a/test/manifest/invalidMediaSequence.js +++ b/test/manifest/invalidMediaSequence.js @@ -21,5 +21,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/invalidPlaylistType.js b/test/manifest/invalidPlaylistType.js index 1590d6827..fc4c2aec5 100644 --- a/test/manifest/invalidPlaylistType.js +++ b/test/manifest/invalidPlaylistType.js @@ -28,5 +28,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/invalidTargetDuration.js b/test/manifest/invalidTargetDuration.js index fde781293..3686308d6 100644 --- a/test/manifest/invalidTargetDuration.js +++ b/test/manifest/invalidTargetDuration.js @@ -140,5 +140,6 @@ "uri": "hls_450k_video.ts" } ], - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/liveMissingSegmentDuration.js b/test/manifest/liveMissingSegmentDuration.js index a2c18395c..95bc58972 100644 --- a/test/manifest/liveMissingSegmentDuration.js +++ b/test/manifest/liveMissingSegmentDuration.js @@ -16,5 +16,6 @@ "uri": "/test/ts-files/tvy7/549c8c77f55f049741a06596e5c1e01dacaa46d0-hi720.ts" } ], - "targetDuration": 8 + "targetDuration": 8, + "discontinuitySequence": 0 } diff --git a/test/manifest/liveStart30sBefore.js b/test/manifest/liveStart30sBefore.js index eae226c63..068f7d06c 100644 --- a/test/manifest/liveStart30sBefore.js +++ b/test/manifest/liveStart30sBefore.js @@ -39,5 +39,6 @@ "uri": "009.ts" } ], - "targetDuration": 10 -} \ No newline at end of file + "targetDuration": 10, + "discontinuitySequence": 0 +} diff --git a/test/manifest/manifestExtTTargetdurationNegative.js b/test/manifest/manifestExtTTargetdurationNegative.js index 7c75f85d5..f34e556e1 100644 --- a/test/manifest/manifestExtTTargetdurationNegative.js +++ b/test/manifest/manifestExtTTargetdurationNegative.js @@ -7,5 +7,6 @@ "uri": "/test/ts-files/zencoder/gogo/00001.ts" } ], - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/manifestExtXEndlistEarly.js b/test/manifest/manifestExtXEndlistEarly.js index 99fb4de46..ff8dc2339 100644 --- a/test/manifest/manifestExtXEndlistEarly.js +++ b/test/manifest/manifestExtXEndlistEarly.js @@ -24,5 +24,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/manifestNoExtM3u.js b/test/manifest/manifestNoExtM3u.js index 578f265e4..25c129e3a 100644 --- a/test/manifest/manifestNoExtM3u.js +++ b/test/manifest/manifestNoExtM3u.js @@ -8,5 +8,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/media.js b/test/manifest/media.js index 4a07d9b50..9b68ea124 100644 --- a/test/manifest/media.js +++ b/test/manifest/media.js @@ -21,5 +21,6 @@ } ], "targetDuration": 10, - "endList": true + "endList": true, + "discontinuitySequence": 0 } diff --git a/test/manifest/mediaSequence.js b/test/manifest/mediaSequence.js index be7fa289a..b5f53cd01 100644 --- a/test/manifest/mediaSequence.js +++ b/test/manifest/mediaSequence.js @@ -21,5 +21,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/missingEndlist.js b/test/manifest/missingEndlist.js index 49e1db8bc..6d9894876 100644 --- a/test/manifest/missingEndlist.js +++ b/test/manifest/missingEndlist.js @@ -11,5 +11,6 @@ "uri": "00002.ts" } ], - "targetDuration": 10 + "targetDuration": 10, + "discontinuitySequence": 0 } diff --git a/test/manifest/missingExtinf.js b/test/manifest/missingExtinf.js index d20d6c6f0..a82e9a58c 100644 --- a/test/manifest/missingExtinf.js +++ b/test/manifest/missingExtinf.js @@ -17,5 +17,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/missingMediaSequence.js b/test/manifest/missingMediaSequence.js index be7fa289a..b5f53cd01 100644 --- a/test/manifest/missingMediaSequence.js +++ b/test/manifest/missingMediaSequence.js @@ -21,5 +21,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/missingSegmentDuration.js b/test/manifest/missingSegmentDuration.js index beaab50be..b34d052f1 100644 --- a/test/manifest/missingSegmentDuration.js +++ b/test/manifest/missingSegmentDuration.js @@ -21,5 +21,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/multipleTargetDurations.js b/test/manifest/multipleTargetDurations.js index e67342672..ca77d2817 100644 --- a/test/manifest/multipleTargetDurations.js +++ b/test/manifest/multipleTargetDurations.js @@ -18,5 +18,6 @@ "uri": "004.ts", "duration": 10 } - ] + ], + "discontinuitySequence": 0 } diff --git a/test/manifest/negativeMediaSequence.js b/test/manifest/negativeMediaSequence.js index 549350f86..0e1cd7c41 100644 --- a/test/manifest/negativeMediaSequence.js +++ b/test/manifest/negativeMediaSequence.js @@ -21,5 +21,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/playlist.js b/test/manifest/playlist.js index 629c49a38..58e49317a 100644 --- a/test/manifest/playlist.js +++ b/test/manifest/playlist.js @@ -141,5 +141,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/playlistMediaSequenceHigher.js b/test/manifest/playlistMediaSequenceHigher.js index 386ddf132..a1526c630 100644 --- a/test/manifest/playlistMediaSequenceHigher.js +++ b/test/manifest/playlistMediaSequenceHigher.js @@ -9,5 +9,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/twoMediaSequences.js b/test/manifest/twoMediaSequences.js index 85ada1581..af7dd8a6d 100644 --- a/test/manifest/twoMediaSequences.js +++ b/test/manifest/twoMediaSequences.js @@ -21,5 +21,6 @@ } ], "targetDuration": 8, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/versionInvalid.js b/test/manifest/versionInvalid.js index 759b1ac42..a100a159b 100644 --- a/test/manifest/versionInvalid.js +++ b/test/manifest/versionInvalid.js @@ -9,5 +9,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/manifest/whiteSpace.js b/test/manifest/whiteSpace.js index 68dd2ea56..208de13f4 100644 --- a/test/manifest/whiteSpace.js +++ b/test/manifest/whiteSpace.js @@ -21,5 +21,6 @@ } ], "targetDuration": 10, - "endList": true -} \ No newline at end of file + "endList": true, + "discontinuitySequence": 0 +} diff --git a/test/metadata-stream_test.js b/test/metadata-stream_test.js index bf6633b3e..e9c4557aa 100644 --- a/test/metadata-stream_test.js +++ b/test/metadata-stream_test.js @@ -186,28 +186,6 @@ // too large/small tag size values // too large/small frame size values - test('translates PTS and DTS values based on the timestamp offset', function() { - var events = []; - metadataStream.on('data', function(event) { - events.push(event); - }); - - metadataStream.timestampOffset = 800; - - metadataStream.push({ - trackId: 7, - pts: 1000, - dts: 900, - - // header - data: new Uint8Array(id3Tag(id3Frame('XFFF', [0]), [0x00, 0x00])) - }); - - equal(events.length, 1, 'emitted an event'); - equal(events[0].pts, 200, 'translated pts'); - equal(events[0].dts, 100, 'translated dts'); - }); - test('parses TXXX frames', function() { var events = []; metadataStream.on('data', function(event) { @@ -223,7 +201,7 @@ data: new Uint8Array(id3Tag(id3Frame('TXXX', 0x03, // utf-8 stringToCString('get done'), - stringToInts('{ "key": "value" }')), + stringToCString('{ "key": "value" }')), [0x00, 0x00])) }); @@ -231,7 +209,7 @@ equal(events[0].frames.length, 1, 'parsed one frame'); equal(events[0].frames[0].id, 'TXXX', 'parsed the frame id'); equal(events[0].frames[0].description, 'get done', 'parsed the description'); - equal(events[0].frames[0].value, '{ "key": "value" }', 'parsed the value'); + deepEqual(JSON.parse(events[0].frames[0].value), { key: 'value' }, 'parsed the value'); }); test('parses WXXX frames', function() { @@ -275,7 +253,7 @@ data: new Uint8Array(id3Tag(id3Frame('TXXX', 0x03, // utf-8 stringToCString(''), - stringToInts(value)), + stringToCString(value)), [0x00, 0x00])) }); diff --git a/test/playlist-loader_test.js b/test/playlist-loader_test.js index 917348b1e..619683d0a 100644 --- a/test/playlist-loader_test.js +++ b/test/playlist-loader_test.js @@ -50,6 +50,20 @@ strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet'); }); + test('starts with no expired time', function() { + var loader = new videojs.Hls.PlaylistLoader('media.m3u8'); + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n'); + equal(loader.expiredPreDiscontinuity_, + 0, + 'zero seconds expired pre-discontinuity'); + equal(loader.expiredPostDiscontinuity_, + 0, + 'zero seconds expired post-discontinuity'); + }); + test('requests the initial playlist immediately', function() { new videojs.Hls.PlaylistLoader('master.m3u8'); strictEqual(requests.length, 1, 'made a request'); @@ -160,6 +174,105 @@ strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct'); }); + test('increments expired seconds after a segment is removed', function() { + var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXTINF:10,\n' + + '1.ts\n' + + '#EXTINF:10,\n' + + '2.ts\n' + + '#EXTINF:10,\n' + + '3.ts\n'); + clock.tick(10 * 1000); // 10s, one target duration + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:1\n' + + '#EXTINF:10,\n' + + '1.ts\n' + + '#EXTINF:10,\n' + + '2.ts\n' + + '#EXTINF:10,\n' + + '3.ts\n' + + '#EXTINF:10,\n' + + '4.ts\n'); + equal(loader.expiredPostDiscontinuity_, 10, 'expired one segment'); + }); + + test('increments expired seconds after a discontinuity', function() { + var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXTINF:3,\n' + + '1.ts\n' + + '#EXT-X-DISCONTINUITY\n' + + '#EXTINF:4,\n' + + '2.ts\n'); + clock.tick(10 * 1000); // 10s, one target duration + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:1\n' + + '#EXTINF:3,\n' + + '1.ts\n' + + '#EXT-X-DISCONTINUITY\n' + + '#EXTINF:4,\n' + + '2.ts\n'); + equal(loader.expiredPreDiscontinuity_, 0, 'identifies pre-discontinuity time'); + equal(loader.expiredPostDiscontinuity_, 10, 'expired one segment'); + + clock.tick(10 * 1000); // 10s, one target duration + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:2\n' + + '#EXT-X-DISCONTINUITY\n' + + '#EXTINF:4,\n' + + '2.ts\n'); + equal(loader.expiredPreDiscontinuity_, 0, 'tracked time across the discontinuity'); + equal(loader.expiredPostDiscontinuity_, 13, 'no expirations after the discontinuity yet'); + + clock.tick(10 * 1000); // 10s, one target duration + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:3\n' + + '#EXT-X-DISCONTINUITY-SEQUENCE:1\n' + + '#EXTINF:10,\n' + + '3.ts\n'); + equal(loader.expiredPreDiscontinuity_, 13, 'did not increment pre-discontinuity'); + equal(loader.expiredPostDiscontinuity_, 4, 'expired post-discontinuity'); + }); + + test('tracks expired seconds properly when two discontinuities expire at once', function() { + var loader = new videojs.Hls.PlaylistLoader('live.m3u8'); + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:4,\n' + + '0.ts\n' + + '#EXT-X-DISCONTINUITY\n' + + '#EXTINF:5,\n' + + '1.ts\n' + + '#EXT-X-DISCONTINUITY\n' + + '#EXTINF:6,\n' + + '2.ts\n' + + '#EXTINF:7,\n' + + '3.ts\n'); + clock.tick(10 * 1000); + requests.pop().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:3\n' + + '#EXT-X-DISCONTINUITY-SEQUENCE:2\n' + + '#EXTINF:7,\n' + + '3.ts\n'); + equal(loader.expiredPreDiscontinuity_, 4 + 5, 'tracked pre-discontinuity time'); + equal(loader.expiredPostDiscontinuity_, 6, 'tracked post-discontinuity time'); + }); + test('emits an error when an initial playlist request fails', function() { var errors = [], @@ -597,4 +710,5 @@ '#EXT-X-ENDLIST'); // no newline ok(loader.media().endList, 'flushed the final line of input'); }); + })(window); diff --git a/test/playlist_test.js b/test/playlist_test.js new file mode 100644 index 000000000..4a249ed8c --- /dev/null +++ b/test/playlist_test.js @@ -0,0 +1,138 @@ +/* Tests for the playlist utilities */ +(function(window, videojs) { + 'use strict'; + var Playlist = videojs.Hls.Playlist; + + module('Playlist Utilities'); + + test('total duration for live playlists is Infinity', function() { + var duration = Playlist.duration({ + segments: [{ + duration: 4, + uri: '0.ts' + }] + }); + + equal(duration, Infinity, 'duration is infinity'); + }); + + test('interval duration accounts for media sequences', function() { + var duration = Playlist.duration({ + mediaSequence: 10, + endList: true, + segments: [{ + duration: 10, + uri: '10.ts' + }, { + duration: 10, + uri: '11.ts' + }, { + duration: 10, + uri: '12.ts' + }, { + duration: 10, + uri: '13.ts' + }] + }, 0, 14); + + equal(duration, 14 * 10, 'duration includes dropped segments'); + }); + + test('calculates seekable time ranges from the available segments', function() { + var playlist = { + mediaSequence: 0, + segments: [{ + duration: 10, + uri: '0.ts' + }, { + duration: 10, + uri: '1.ts' + }], + endList: true + }, seekable = Playlist.seekable(playlist); + + equal(seekable.length, 1, 'there are seekable ranges'); + equal(seekable.start(0), 0, 'starts at zero'); + equal(seekable.end(0), Playlist.duration(playlist), 'ends at the duration'); + }); + + test('master playlists have empty seekable ranges', function() { + var seekable = Playlist.seekable({ + playlists: [{ + uri: 'low.m3u8' + }, { + uri: 'high.m3u8' + }] + }); + equal(seekable.length, 0, 'no seekable ranges from a master playlist'); + }); + + test('seekable end is three target durations from the actual end of live playlists', function() { + var seekable = Playlist.seekable({ + mediaSequence: 0, + segments: [{ + duration: 7, + uri: '0.ts' + }, { + duration: 10, + uri: '1.ts' + }, { + duration: 10, + uri: '2.ts' + }, { + duration: 10, + uri: '3.ts' + }] + }); + equal(seekable.length, 1, 'there are seekable ranges'); + equal(seekable.start(0), 0, 'starts at zero'); + equal(seekable.end(0), 7, 'ends three target durations from the last segment'); + }); + + test('adjusts seekable to the live playlist window', function() { + var seekable = Playlist.seekable({ + targetDuration: 10, + mediaSequence: 7, + segments: [{ + uri: '8.ts' + }, { + uri: '9.ts' + }, { + uri: '10.ts' + }, { + uri: '11.ts' + }] + }); + equal(seekable.length, 1, 'there are seekable ranges'); + equal(seekable.start(0), 10 * 7, 'starts at the earliest available segment'); + equal(seekable.end(0), 10 * 8, 'ends three target durations from the last available segment'); + }); + + test('seekable end accounts for non-standard target durations', function() { + var seekable = Playlist.seekable({ + targetDuration: 2, + mediaSequence: 0, + 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' + }] + }); + equal(seekable.start(0), 0, 'starts at the earliest available segment'); + equal(seekable.end(0), + 9 - (2 * 3), + 'allows seeking no further than three target durations from the end'); + }); + +})(window, window.videojs); diff --git a/test/videojs-hls.html b/test/videojs-hls.html index 8c03119fa..6488c8fe9 100644 --- a/test/videojs-hls.html +++ b/test/videojs-hls.html @@ -15,7 +15,7 @@ - + @@ -32,6 +32,7 @@ + @@ -59,6 +60,7 @@ + diff --git a/test/videojs-hls_test.js b/test/videojs-hls_test.js index bef9a017f..d15ba7ff1 100644 --- a/test/videojs-hls_test.js +++ b/test/videojs-hls_test.js @@ -293,6 +293,31 @@ test('calculates the duration if needed', function() { 'duration is calculated'); }); +test('translates seekable by the starting time for live playlists', function() { + var seekable; + player.src({ + src: 'media.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + openMediaSource(player); + requests.shift().respond(200, null, + '#EXTM3U\n' + + '#EXT-X-MEDIA-SEQUENCE:15\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXTINF:10,\n' + + '1.ts\n' + + '#EXTINF:10,\n' + + '2.ts\n' + + '#EXTINF:10,\n' + + '3.ts\n'); + + seekable = player.seekable(); + equal(seekable.length, 1, 'one seekable range'); + equal(seekable.start(0), 0, 'the earliest possible position is at zero'); + equal(seekable.end(0), 10, 'end is relative to the start'); +}); + test('starts downloading a segment on loadedmetadata', function() { player.src({ src: 'manifest/media.m3u8', @@ -1224,6 +1249,226 @@ test('exposes in-band metadata events as cues', function() { 'set the private data'); }); +test('only adds in-band cues the first time they are encountered', function() { + var tags = [{ pts: 0, bytes: new Uint8Array(1) }], track; + player.src({ + src: 'manifest/media.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + openMediaSource(player); + + player.hls.segmentParser_.getNextTag = function() { + return tags.shift(); + }; + player.hls.segmentParser_.tagsAvailable = function() { + return tags.length; + }; + player.hls.segmentParser_.parseSegmentBinaryData = function() { + // fake out a descriptor + player.hls.segmentParser_.metadataStream.descriptor = new Uint8Array([ + 1, 2, 3, 0xbb + ]); + // trigger a metadata event + player.hls.segmentParser_.metadataStream.trigger('data', { + pts: 2000, + data: new Uint8Array([]), + frames: [{ + id: 'TXXX', + value: 'cue text' + }] + }); + }; + standardXHRResponse(requests.shift()); + standardXHRResponse(requests.shift()); + // seek back to the first segment + player.currentTime(0); + player.hls.trigger('seeking'); + tags.push({ pts: 0, bytes: new Uint8Array(1) }); + standardXHRResponse(requests.shift()); + + track = player.textTracks()[0]; + equal(track.cues.length, 1, 'only added the cue once'); +}); + +test('clears in-band cues ahead of current time on seek', function() { + var + tags = [], + events = [], + track; + player.src({ + src: 'manifest/media.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + openMediaSource(player); + + player.hls.segmentParser_.getNextTag = function() { + return tags.shift(); + }; + player.hls.segmentParser_.tagsAvailable = function() { + return tags.length; + }; + player.hls.segmentParser_.parseSegmentBinaryData = function() { + // fake out a descriptor + player.hls.segmentParser_.metadataStream.descriptor = new Uint8Array([ + 1, 2, 3, 0xbb + ]); + // trigger a metadata event + if (events.length) { + player.hls.segmentParser_.metadataStream.trigger('data', events.shift()); + } + }; + standardXHRResponse(requests.shift()); // media + tags.push({ pts: 10 * 1000, bytes: new Uint8Array(1) }); + events.push({ + pts: 20 * 1000, + data: new Uint8Array([]), + frames: [{ + id: 'TXXX', + value: 'cue 3' + }] + }); + events.push({ + pts: 9.9 * 1000, + data: new Uint8Array([]), + frames: [{ + id: 'TXXX', + value: 'cue 1' + }] + }); + standardXHRResponse(requests.shift()); // segment 0 + tags.push({ pts: 20 * 1000, bytes: new Uint8Array(1) }); + events.push({ + pts: 19.9 * 1000, + data: new Uint8Array([]), + frames: [{ + id: 'TXXX', + value: 'cue 2' + }] + }); + player.hls.checkBuffer_(); + standardXHRResponse(requests.shift()); // segment 1 + + track = player.textTracks()[0]; + equal(track.cues.length, 2, 'added the cues'); + + // seek into segment 1 + player.currentTime(11); + player.trigger('seeking'); + equal(track.cues.length, 1, 'removed a cue'); + equal(track.cues[0].startTime, 9.9, 'retained the earlier cue'); +}); + +test('translates ID3 PTS values to cue media timeline positions', function() { + var tags = [{ pts: 4 * 1000, bytes: new Uint8Array(1) }], track; + player.src({ + src: 'manifest/media.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + openMediaSource(player); + + player.hls.segmentParser_.getNextTag = function() { + return tags.shift(); + }; + player.hls.segmentParser_.tagsAvailable = function() { + return tags.length; + }; + player.hls.segmentParser_.parseSegmentBinaryData = function() { + // setup the timestamp offset + this.timestampOffset = tags[0].pts; + + // fake out a descriptor + player.hls.segmentParser_.metadataStream.descriptor = new Uint8Array([ + 1, 2, 3, 0xbb + ]); + // trigger a metadata event + player.hls.segmentParser_.metadataStream.trigger('data', { + pts: 5 * 1000, + data: new Uint8Array([]), + frames: [{ + id: 'TXXX', + value: 'cue text' + }] + }); + }; + standardXHRResponse(requests.shift()); // media + standardXHRResponse(requests.shift()); // segment 0 + + track = player.textTracks()[0]; + equal(track.cues[0].startTime, 1, 'translated startTime'); + equal(track.cues[0].endTime, 1, 'translated startTime'); +}); + +test('translates ID3 PTS values across discontinuities', function() { + var tags = [], events = [], track; + player.src({ + src: 'cues-and-discontinuities.m3u8', + type: 'application/vnd.apple.mpegurl' + }); + openMediaSource(player); + + player.hls.segmentParser_.getNextTag = function() { + return tags.shift(); + }; + player.hls.segmentParser_.tagsAvailable = function() { + return tags.length; + }; + player.hls.segmentParser_.parseSegmentBinaryData = function() { + if (this.timestampOffset === null) { + this.timestampOffset = tags[0].pts; + } + // fake out a descriptor + player.hls.segmentParser_.metadataStream.descriptor = new Uint8Array([ + 1, 2, 3, 0xbb + ]); + // trigger a metadata event + if (events.length) { + player.hls.segmentParser_.metadataStream.trigger('data', events.shift()); + } + }; + + // media playlist + requests.shift().respond(200, null, + '#EXTM3U\n' + + '#EXTINF:10,\n' + + '0.ts\n' + + '#EXT-X-DISCONTINUITY\n' + + '#EXTINF:10,\n' + + '1.ts\n'); + + // segment 0 starts at PTS 14000 and has a cue point at 15000 + tags.push({ pts: 14 * 1000, bytes: new Uint8Array(1) }); + events.push({ + pts: 15 * 1000, + data: new Uint8Array([]), + frames: [{ + id: 'TXXX', + value: 'cue 0' + }] + }); + standardXHRResponse(requests.shift()); // segment 0 + + // segment 1 is after a discontinuity, starts at PTS 22000 + // and has a cue point at 15000 + tags.push({ pts: 22 * 1000, bytes: new Uint8Array(1) }); + events.push({ + pts: 23 * 1000, + data: new Uint8Array([]), + frames: [{ + id: 'TXXX', + value: 'cue 0' + }] + }); + player.hls.checkBuffer_(); + standardXHRResponse(requests.shift()); + + track = player.textTracks()[0]; + equal(track.cues.length, 2, 'created cues'); + equal(track.cues[0].startTime, 1, 'first cue started at the correct time'); + equal(track.cues[0].endTime, 1, 'first cue ended at the correct time'); + equal(track.cues[1].startTime, 11, 'second cue started at the correct time'); + equal(track.cues[1].endTime, 11, 'second cue ended at the correct time'); +}); + test('drops tags before the target timestamp when seeking', function() { var i = 10, tags = [], @@ -1611,7 +1856,8 @@ test('clears the segment buffer on seek', function() { '1.ts\n' + '#EXT-X-DISCONTINUITY\n' + '#EXTINF:10,0\n' + - '2.ts\n'); + '2.ts\n' + + '#EXT-X-ENDLIST\n'); standardXHRResponse(requests.pop()); // play to 6s to trigger the next segment request @@ -1662,7 +1908,8 @@ test('continues playing after seek to discontinuity', function() { '1.ts\n' + '#EXT-X-DISCONTINUITY\n' + '#EXTINF:10,0\n' + - '2.ts\n'); + '2.ts\n' + + '#EXT-X-ENDLIST\n'); standardXHRResponse(requests.pop()); currentTime = 1; @@ -1751,8 +1998,7 @@ test('remove event handlers on dispose', function() { player.dispose(); - ok(offhandlers > onhandlers, 'more handlers were removed than were registered'); - equal(offhandlers - onhandlers, 1, 'one handler was registered during init'); + ok(offhandlers > onhandlers, 'removed all registered handlers'); }); test('aborts the source buffer on disposal', function() {