Skip to content

Commit

Permalink
Add allowSeeksWithinUnsafeLiveWindow property (#320)
Browse files Browse the repository at this point in the history
* Add allowSeeksWithinUnsafeLiveWindow property

This property will prevent the playback watcher from attempting to
correct seeks that fall outside of the safe live window, but still fall
within the playlist boundaries.

It can help resolve issues where the playback watcher's gap skipper tries
to seek past a gap, but the playback watcher then detects an illegal seek
and corrects it to the safe live point, which, being content already
played, leads to repeated content.
gesinger authored and forbesjo committed Dec 21, 2018
1 parent 895c86a commit 74b28e8
Showing 4 changed files with 238 additions and 20 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -48,6 +48,7 @@ Video.js Compatibility: 6.0, 7.0
- [enableLowInitialPlaylist](#enablelowinitialplaylist)
- [limitRenditionByPlayerDimensions](#limitrenditionbyplayerdimensions)
- [smoothQualityChange](#smoothqualitychange)
- [allowSeeksWithinUnsafeLiveWindow](#allowSeeksWithinUnsafeLiveWindow)
- [Runtime Properties](#runtime-properties)
- [hls.playlists.master](#hlsplaylistsmaster)
- [hls.playlists.media](#hlsplaylistsmedia)
@@ -374,6 +375,25 @@ Note that this _only_ affects quality changes triggered via the representations
API; automatic quality switches based on available bandwidth will always be
smooth switches.

##### allowSeeksWithinUnsafeLiveWindow
* Type: `boolean`
* can be used as a source option

When `allowSeeksWithinUnsafeLiveWindow` is set to `true`, if the active playlist is live
and a seek is made to a time between the safe live point (end of manifest minus three
times the target duration,
see [the hls spec](https://tools.ietf.org/html/draft-pantos-http-live-streaming-23#section-6.3.3)
for details) and the end of the playlist, the seek is allowed, rather than corrected to
the safe live point.

This option can help in instances where the live stream's target duration is greater than
the segment durations, playback ends up in the unsafe live window, and there are gaps in
the content. In this case the player will attempt to seek past the gaps but end up seeking
inside of the unsafe range, leading to a correction and seek back into a previously played
content.

The property defaults to `false`.

### Runtime Properties
Runtime properties are attached to the tech object when HLS is in
use. You can get a reference to the HLS source handler like this:
29 changes: 25 additions & 4 deletions src/playback-watcher.js
Original file line number Diff line number Diff line change
@@ -34,6 +34,8 @@ export default class PlaybackWatcher {
this.tech_ = options.tech;
this.seekable = options.seekable;
this.seekTo = options.seekTo;
this.allowSeeksWithinUnsafeLiveWindow = options.allowSeeksWithinUnsafeLiveWindow;
this.media = options.media;

this.consecutiveUpdates = 0;
this.lastRecordedTime = null;
@@ -153,18 +155,29 @@ export default class PlaybackWatcher {
*/
fixesBadSeeks_() {
const seeking = this.tech_.seeking();

if (!seeking) {
return false;
}

const seekable = this.seekable();
const currentTime = this.tech_.currentTime();
const isAfterSeekableRange = this.afterSeekableWindow_(
seekable,
currentTime,
this.media(),
this.allowSeeksWithinUnsafeLiveWindow
);
let seekTo;

if (seeking && this.afterSeekableWindow_(seekable, currentTime)) {
if (isAfterSeekableRange) {
const seekableEnd = seekable.end(seekable.length - 1);

// sync to live point (if VOD, our seekable was updated and we're simply adjusting)
seekTo = seekableEnd;
}

if (seeking && this.beforeSeekableWindow_(seekable, currentTime)) {
if (this.beforeSeekableWindow_(seekable, currentTime)) {
const seekableStart = seekable.start(0);

// sync to the beginning of the live window
@@ -290,13 +303,21 @@ export default class PlaybackWatcher {
return false;
}

afterSeekableWindow_(seekable, currentTime) {
afterSeekableWindow_(
seekable, currentTime, playlist, allowSeeksWithinUnsafeLiveWindow = false) {
if (!seekable.length) {
// we can't make a solid case if there's no seekable, default to false
return false;
}

if (currentTime > seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA) {
let allowedEnd = seekable.end(seekable.length - 1) + Ranges.SAFE_TIME_DELTA;
const isLive = !playlist.endList;

if (isLive && allowSeeksWithinUnsafeLiveWindow) {
allowedEnd = seekable.end(seekable.length - 1) + (playlist.targetDuration * 3);
}

if (currentTime > allowedEnd) {
return true;
}

3 changes: 2 additions & 1 deletion src/videojs-http-streaming.js
Original file line number Diff line number Diff line change
@@ -468,7 +468,8 @@ class HlsHandler extends Component {
this.masterPlaylistController_ = new MasterPlaylistController(this.options_);
this.playbackWatcher_ = new PlaybackWatcher(
videojs.mergeOptions(this.options_, {
seekable: () => this.seekable()
seekable: () => this.seekable(),
media: () => this.masterPlaylistController_.media()
}));

this.masterPlaylistController_.on('error', () => {
206 changes: 191 additions & 15 deletions test/playback-watcher.test.js
Original file line number Diff line number Diff line change
@@ -548,6 +548,65 @@ QUnit.test('corrects seek outside of seekable', function(assert) {
assert.equal(seeks.length, 4, 'did not seek');
});

QUnit.test('corrected seeks respect allowSeeksWithinUnsafeLiveWindow flag',
function(assert) {
// set an arbitrary live source
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);

let playbackWatcher = this.player.tech_.hls.playbackWatcher_;
let seeks = [];
let seekable;
let seeking;
let currentTime;

playbackWatcher.seekable = () => seekable;
playbackWatcher.tech_ = {
off: () => {},
seeking: () => seeking,
currentTime: () => currentTime,
// mocked out
paused: () => false,
buffered: () => videojs.createTimeRanges()
};
this.player.vhs.setCurrentTime = (time) => seeks.push(time);

playbackWatcher.allowSeeksWithinUnsafeLiveWindow = true;

// waiting

seekable = videojs.createTimeRanges([[1, 45]]);
seeking = true;

// target duration of 10, seekable end of 45
// 45 + 3 * 10 = 75
currentTime = 75;
this.player.tech_.trigger('waiting');
assert.equal(seeks.length, 0, 'did not seek');

currentTime = 75.1;
this.player.tech_.trigger('waiting');
assert.equal(seeks.length, 1, 'seeked');
assert.equal(seeks[0], 45, 'player seeked to live point');

playbackWatcher.allowSeeksWithinUnsafeLiveWindow = true;

currentTime = 75;
this.player.tech_.trigger('waiting');
assert.equal(seeks.length, 1, 'did not seek');
});

QUnit.test('calls fixesBadSeeks_ on seekablechanged', function(assert) {
// set an arbitrary live source
this.player.src({
@@ -675,36 +734,153 @@ QUnit.test('detects live window falloff', function(assert) {
'true if current time is 0 and earlier than seekable range');
});

QUnit.test('detects beyond seekable window', function(assert) {
QUnit.test('detects beyond seekable window for VOD', function(assert) {
const playlist = {
endList: true,
targetDuration: 7
};
let afterSeekableWindow_ =
this.playbackWatcher.afterSeekableWindow_.bind(this.playbackWatcher);

assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8),
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist),
'false if before seekable range');
assert.ok(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2),
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist),
'true if after seekable range');
assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9),
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist),
'false if within starting seekable range buffer');
assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1),
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist),
'false if within ending seekable range buffer');

assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges(), 10),
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist),
'false if no seekable range');
assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2),
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist),
'false if current time is negative');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist),
'false if within seekable range');
});

QUnit.test('detects beyond seekable window for LIVE', function(assert) {
// no endList means live
const playlist = {
targetDuration: 7
};
let afterSeekableWindow_ =
this.playbackWatcher.afterSeekableWindow_.bind(this.playbackWatcher);

assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist),
'false if before seekable range');
assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5),
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist),
'true if after seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist),
'false if within starting seekable range buffer');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist),
'false if within ending seekable range buffer');

assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist),
'false if no seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist),
'false if current time is negative');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist),
'false if within seekable range');
});

QUnit.test('respects allowSeeksWithinUnsafeLiveWindow flag', function(assert) {
// no endList means live
const playlist = {
targetDuration: 7
};
let afterSeekableWindow_ =
this.playbackWatcher.afterSeekableWindow_.bind(this.playbackWatcher);

assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist, true),
'false if before seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist, true),
'false if after seekable range but within unsafe live window');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 40.9, playlist, true),
'false if after seekable range but within unsafe live window');
assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0),
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 41.1, playlist, true),
'true if after seekable range and unsafe live window');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist, true),
'false if within starting seekable range buffer');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist, true),
'false if within ending seekable range buffer');

assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist, true),
'false if no seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist, true),
'false if current time is negative');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist, true),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist, true),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist, true),
'false if within seekable range');

playlist.endList = true;

assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.8, playlist, true),
'false if before seekable range');
assert.ok(
!afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10),
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.2, playlist, true),
'true if after seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 10.9, playlist, true),
'false if within starting seekable range buffer');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[11, 20]]), 20.1, playlist, true),
'false if within ending seekable range buffer');

assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges(), 10, playlist, true),
'false if no seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), -0.2, playlist, true),
'false if current time is negative');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 5, playlist, true),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 0, playlist, true),
'false if within seekable range');
assert.notOk(
afterSeekableWindow_(videojs.createTimeRanges([[0, 10]]), 10, playlist, true),
'false if within seekable range');
});

0 comments on commit 74b28e8

Please sign in to comment.