Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JW8-5661] Implement preliminary playlist refresh synchronization #205

Merged
merged 8 commits into from
Apr 16, 2019
76 changes: 49 additions & 27 deletions src/controller/audio-track-controller.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Event from '../events';
import TaskLoop from '../task-loop';
import { logger } from '../utils/logger';
import { ErrorTypes, ErrorDetails } from '../errors';
import { computeReloadInterval } from './level-helper';
import EventHandler from '../event-handler';

/**
* @class AudioTrackController
Expand All @@ -24,7 +25,7 @@ import { ErrorTypes, ErrorDetails } from '../errors';
* @fires ERROR
*
*/
class AudioTrackController extends TaskLoop {
class AudioTrackController extends EventHandler {
constructor (hls) {
super(hls,
Event.MANIFEST_LOADING,
Expand Down Expand Up @@ -70,6 +71,13 @@ class AudioTrackController extends TaskLoop {
* @member {string}
*/
this.audioGroupId = null;

this.canload = false;
this.timer = null;
}

onHandlerDestroying () {
this.clearTimer();
}

/**
Expand Down Expand Up @@ -101,30 +109,51 @@ class AudioTrackController extends TaskLoop {
* @param {} data
*/
onAudioTrackLoaded (data) {
if (data.id >= this.tracks.length) {
logger.warn('Invalid audio track id:', data.id);
const { id, details } = data;

if (id >= this.tracks.length) {
logger.warn('Invalid audio track id:', id);
return;
}

logger.log(`audioTrack ${data.id} loaded`);

this.tracks[data.id].details = data.details;
logger.log(`audioTrack ${id} loaded [${details.startSN},${details.endSN}]`);

// if current playlist is a live playlist, arm a timer to reload it
if (details.live) {
const curDetails = this.tracks[id].details;
details.updated = (!curDetails || details.endSN !== curDetails.endSN || details.url !== curDetails.url);
details.availabilityDelay = curDetails && curDetails.availabilityDelay;
const reloadInterval = computeReloadInterval(details, data.stats, 'audio track');
logger.log(`live audio track ${details.updated ? 'REFRESHED' : 'MISSED'}, reload in ${Math.round(reloadInterval)} ms`);
// Stop reloading if the timer was cleared
if (this.canload) {
this.timer = setTimeout(() => this._updateTrack(this._trackId), reloadInterval);
}
} else {
// playlist is not live and timer is scheduled: cancel it
this.clearTimer();
}
}

// check if current playlist is a live playlist
// and if we have already our reload interval setup
if (data.details.live && !this.hasInterval()) {
// if live playlist we will have to reload it periodically
// set reload period to playlist target duration
const updatePeriodMs = data.details.targetduration * 1000;
this.setInterval(updatePeriodMs);
clearTimer () {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}

if (!data.details.live && this.hasInterval()) {
// playlist is not live and timer is scheduled: cancel it
this.clearInterval();
startLoad () {
this.canload = true;
if (this.timer === null) {
this._updateTrack(this._trackId);
}
}

stopLoad () {
this.canload = false;
this.clearTimer();
}

/**
* Update the internal group ID to any audio-track we may have set manually
* or because of a failure-handling fallback.
Expand Down Expand Up @@ -180,7 +209,7 @@ class AudioTrackController extends TaskLoop {

// If fatal network error, cancel update task
if (data.fatal) {
this.clearInterval();
this.clearTimer();
}

// If not an audio-track loading error don't handle further
Expand Down Expand Up @@ -237,21 +266,14 @@ class AudioTrackController extends TaskLoop {
logger.log(`Now switching to audio-track index ${newId}`);

// stopping live reloading timer if any
this.clearInterval();
this.clearTimer();
this._trackId = newId;

const { url, type, id } = audioTrack;
this.hls.trigger(Event.AUDIO_TRACK_SWITCHING, { id, type, url });
this._loadTrackDetailsIfNeeded(audioTrack);
}

/**
* @override
*/
doTick () {
this._updateTrack(this._trackId);
}

/**
* Select initial track
* @private
Expand Down Expand Up @@ -352,7 +374,7 @@ class AudioTrackController extends TaskLoop {
}

// stopping live reloading timer if any
this.clearInterval();
this.clearTimer();
this._trackId = newId;
logger.log(`trying to update audio-track ${newId}`);
const audioTrack = this.tracks[newId];
Expand Down
13 changes: 8 additions & 5 deletions src/controller/level-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export default class LevelController extends EventHandler {

stopLoad () {
this.canload = false;
this.clearTimer();
}

onManifestLoaded (data) {
Expand Down Expand Up @@ -396,8 +397,11 @@ export default class LevelController extends EventHandler {
}
// if current playlist is a live playlist, arm a timer to reload it
if (details.live) {
const reloadInterval = computeReloadInterval(curLevel.details, details, data.stats.trequest);
logger.log(`live playlist, reload in ${Math.round(reloadInterval)} ms`);
const curDetails = curLevel.details;
details.updated = (!curDetails || details.endSN !== curDetails.endSN || details.url !== curDetails.url);
details.availabilityDelay = curDetails && curDetails.availabilityDelay;
const reloadInterval = computeReloadInterval(details, data.stats);
logger.log(`live playlist ${details.updated ? 'REFRESHED' : 'MISSED'}, reload in ${Math.round(reloadInterval)} ms`);
this.timer = setTimeout(() => this.loadLevel(), reloadInterval);
} else {
this.clearTimer();
Expand Down Expand Up @@ -430,13 +434,12 @@ export default class LevelController extends EventHandler {
}

loadLevel () {
logger.debug('call to loadLevel');
logger.debug(`call to loadLevel (canload ${this.canload})`);

if (this.currentLevelIndex !== null && this.canload) {
const levelObject = this._levels[this.currentLevelIndex];

if (typeof levelObject === 'object' &&
levelObject.url.length > 0) {
if (typeof levelObject === 'object' && levelObject.url.length > 0) {
const level = this.currentLevelIndex;
const id = levelObject.urlId;
const url = levelObject.url[id];
Expand Down
65 changes: 53 additions & 12 deletions src/controller/level-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,21 +207,62 @@ export function adjustSliding (oldPlaylist, newPlaylist) {
}
}

export function computeReloadInterval (currentPlaylist, newPlaylist, lastRequestTime) {
let reloadInterval = 1000 * (newPlaylist.averagetargetduration ? newPlaylist.averagetargetduration : newPlaylist.targetduration);
const minReloadInterval = reloadInterval / 2;
if (currentPlaylist && newPlaylist.endSN === currentPlaylist.endSN) {
// follow HLS Spec, If the client reloads a Playlist file and finds that it has not
// changed then it MUST wait for a period of one-half the target
// duration before retrying.
reloadInterval = minReloadInterval;
export function computeReloadInterval (newDetails, stats) {
const reloadInterval = 1000 * (newDetails.averagetargetduration ? newDetails.averagetargetduration : newDetails.targetduration);
const reloadIntervalAfterMiss = reloadInterval / 2;
const timeSinceLastModified = newDetails.lastModified ? new Date() - newDetails.lastModified : 0;
const useLastModified = timeSinceLastModified > 0 && timeSinceLastModified < reloadInterval * 3;
const roundTrip = stats ? stats.tload - stats.trequest : 0;

let estimatedTimeUntilUpdate = reloadInterval;
let availabilityDelay = newDetails.availabilityDelay;
// let estimate = 'average';

if (newDetails.updated === false) {
if (useLastModified) {
// estimate = 'miss round trip';
// We should have had a hit so try again in the time it takes to get a response,
// but no less than ~1/4 second. After 4 retries, at least 1.1 seconds will have gone by.
const minRetry = 283;
estimatedTimeUntilUpdate = Math.max(Math.min(reloadIntervalAfterMiss, roundTrip), minRetry);
} else {
// estimate = 'miss half average';
// follow HLS Spec, If the client reloads a Playlist file and finds that it has not
// changed then it MUST wait for a period of one-half the target
// duration before retrying.
estimatedTimeUntilUpdate = reloadIntervalAfterMiss;
}
} else if (useLastModified) {
// estimate = 'next modified date';
// Get the closest we've been to timeSinceLastModified on update
availabilityDelay = Math.min(availabilityDelay || reloadInterval / 2, timeSinceLastModified);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So on the first update availabilityDelay will be equal to half the reload interval?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's assuming that we don't have a previous lastModified or availabilityDelay

Copy link
Author

@robwalch robwalch Mar 29, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We start by assuming it takes half a target duration from the time the last segment is encoded to the time when the manifest update becomes available (We'll still cut this in half again below).

// TODO: Network controllers should maintain this and other stats, rather than passing it from one level details to then next after each response
newDetails.availabilityDelay = availabilityDelay;
// TODO: Back off from reloading too close to the time the server is expected to update, rather than using this hardcoded value
const minAvailabilityDelay = 1000;
estimatedTimeUntilUpdate = Math.max(availabilityDelay / 2, minAvailabilityDelay) + reloadInterval - timeSinceLastModified;
} else {
estimatedTimeUntilUpdate = reloadInterval - roundTrip;
}

if (lastRequestTime) {
reloadInterval = Math.max(minReloadInterval, reloadInterval - (window.performance.now() - lastRequestTime));
// console.log(`[computeReloadInterval] live reload ${newDetails.updated ? 'REFRESHED' : 'MISSED'}`,
// '\n method', estimate,
// '\n estimated time until update =>', estimatedTimeUntilUpdate,
// '\n average target duration', reloadInterval,
// '\n time since modified', timeSinceLastModified,
// '\n time round trip', roundTrip,
// '\n availability delay', availabilityDelay);

return Math.round(estimatedTimeUntilUpdate);
}

export function getProgramDateTimeAtEndOfLastEncodedFragment (levelDetails) {
if (levelDetails.hasProgramDateTime) {
const encodedFragments = levelDetails.fragments.filter((fragment) => !fragment.prefetch);
const lastEncodedFrag = encodedFragments[encodedFragments.length - 1];
return lastEncodedFrag.programDateTime + lastEncodedFrag.duration * 1000;
}
// in any case, don't reload more than half of target duration
return Math.round(reloadInterval);
return null;
}

export function getFragmentWithSN (level, sn) {
Expand Down
10 changes: 7 additions & 3 deletions src/controller/subtitle-track-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,14 @@ class SubtitleTrackController extends EventHandler {
return;
}

logger.log(`subtitle track ${id} loaded`);
logger.log(`subtitle track ${id} loaded [${details.startSN},${details.endSN}]`);

if (details.live) {
const reloadInterval = computeReloadInterval(currentTrack.details, details, data.stats.trequest);
logger.log(`Reloading live subtitle playlist in ${reloadInterval}ms`);
const curDetails = currentTrack.details;
details.updated = (!curDetails || details.endSN !== curDetails.endSN || details.url !== curDetails.url);
details.availabilityDelay = curDetails && curDetails.availabilityDelay;
const reloadInterval = computeReloadInterval(details, data.stats);
logger.log(`live subtitle track ${details.updated ? 'REFRESHED' : 'MISSED'}, reload in ${Math.round(reloadInterval)} ms`);
this.timer = setTimeout(() => {
this._loadCurrentTrack();
}, reloadInterval);
Expand Down
84 changes: 32 additions & 52 deletions src/hls.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ export default class Hls extends Observer {
const capLevelController = new config.capLevelController(this); // eslint-disable-line new-cap
const fpsController = new config.fpsController(this); // eslint-disable-line new-cap
const playListLoader = new PlaylistLoader(this);
// const fragmentLoader = new FragmentLoader(this);
const keyLoader = new KeyLoader(this);

// network controllers
Expand All @@ -140,16 +139,10 @@ export default class Hls extends Observer {
*/
const streamController = this.streamController = new StreamController(this, fragmentTracker);

let networkControllers = [levelController, streamController];

// optional audio stream controller
/**
* @var {ICoreComponent | Controller}
*/
let Controller = config.audioStreamController;
if (Controller) {
networkControllers.push(new Controller(this, fragmentTracker));
}
let networkControllers = [
levelController,
streamController
];

/**
* @member {INetworkController[]} networkControllers
Expand All @@ -161,7 +154,6 @@ export default class Hls extends Observer {
*/
const coreComponents = [
playListLoader,
// fragmentLoader,
keyLoader,
abrController,
bufferController,
Expand All @@ -170,56 +162,44 @@ export default class Hls extends Observer {
fragmentTracker
];

// optional audio track and subtitle controller
Controller = config.audioTrackController;
if (Controller) {
const audioTrackController = new Controller(this);

/**
* @member {AudioTrackController} audioTrackController
*/
this.audioTrackController = audioTrackController;
coreComponents.push(audioTrackController);
}

Controller = config.subtitleTrackController;
if (Controller) {
const subtitleTrackController = new Controller(this);

/**
* @member {SubtitleTrackController} subtitleTrackController
*/
this.subtitleTrackController = subtitleTrackController;
networkControllers.push(subtitleTrackController);
}
// audioTrackController must be defined before audioStreamController because the order of event handling is important
/**
* @member {AudioTrackController} audioTrackController
*/
this.audioTrackController = this.createController(config.audioTrackController, null, networkControllers);
this.createController(config.audioStreamController, fragmentTracker, networkControllers);

Controller = config.emeController;
if (Controller) {
const emeController = new Controller(this);
// subtitleTrackController must be defined before because the order of event handling is important
/**
* @member {SubtitleTrackController} subtitleTrackController
*/
this.subtitleTrackController = this.createController(config.subtitleTrackController, null, networkControllers);
this.createController(config.subtitleStreamController, fragmentTracker, networkControllers);

/**
* @member {EMEController} emeController
*/
this.emeController = emeController;
coreComponents.push(emeController);
}
this.createController(config.timelineController, null, coreComponents);

// optional subtitle controllers
Controller = config.subtitleStreamController;
if (Controller) {
networkControllers.push(new Controller(this, fragmentTracker));
}
Controller = config.timelineController;
if (Controller) {
coreComponents.push(new Controller(this));
}
/**
* @member {EMEController} emeController
*/
this.emeController = this.createController(config.emeController, null, coreComponents);

/**
* @member {ICoreComponent[]}
*/
this.coreComponents = coreComponents;
}

createController (ControllerClass, fragmentTracker, components) {
if (ControllerClass) {
const controllerInstance = fragmentTracker ? new ControllerClass(this, fragmentTracker) : new ControllerClass(this);
if (components) {
components.push(controllerInstance);
}
return controllerInstance;
}
return null;
}

/**
* Dispose of the instance
*/
Expand Down
Loading