mirror of
https://github.com/shaka-project/shaka-player.git
synced 2026-06-16 16:16:40 +03:00
38ce45dce5
Cleanup imported from an internal Google migration process, courtesy of Laura Harker. Change-Id: I11de518eafe6008938589e5250bdcaf8151267e9
588 lines
20 KiB
JavaScript
588 lines
20 KiB
JavaScript
/*! @license
|
|
* Shaka Player
|
|
* Copyright 2016 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
goog.provide('shaka.routing.Walker');
|
|
|
|
goog.require('goog.asserts');
|
|
goog.require('shaka.routing.Node');
|
|
goog.require('shaka.routing.Payload');
|
|
goog.require('shaka.util.Destroyer');
|
|
goog.require('shaka.util.Error');
|
|
goog.require('shaka.util.IDestroyable');
|
|
goog.require('shaka.util.PublicPromise');
|
|
goog.requireType('shaka.util.AbortableOperation');
|
|
|
|
|
|
/**
|
|
* The walker moves through a graph node-by-node executing asynchronous work
|
|
* as it enters each node.
|
|
*
|
|
* The walker accepts requests for where it should go next. Requests are queued
|
|
* and executed in FIFO order. If the current request can be interrupted, it
|
|
* will be cancelled and the next request started.
|
|
*
|
|
* A request says "I want to change where we are going". When the walker is
|
|
* ready to change destinations, it will resolve the request, allowing the
|
|
* destination to differ based on the current state and not the state when
|
|
* the request was appended.
|
|
*
|
|
* Example (from shaka.Player):
|
|
* When we unload, we need to either go to the attached or detached state based
|
|
* on whether or not we have a video element.
|
|
*
|
|
* When we are asked to unload, we don't know what other pending requests may
|
|
* be ahead of us (there could be attach requests or detach requests). We need
|
|
* to wait until its our turn to know if:
|
|
* - we should go to the attach state because we have a media element
|
|
* - we should go to the detach state because we don't have a media element
|
|
*
|
|
* The walker allows the caller to specify if a route can or cannot be
|
|
* interrupted. This is to allow potentially dependent routes to wait until
|
|
* other routes have finished.
|
|
*
|
|
* Example (from shaka.Player):
|
|
* A request to load content depends on an attach request finishing. We don't
|
|
* want load request to interrupt an attach request. By marking the attach
|
|
* request as non-interruptible we ensure that calling load before attach
|
|
* finishes will work.
|
|
*
|
|
* @implements {shaka.util.IDestroyable}
|
|
* @final
|
|
*/
|
|
shaka.routing.Walker = class {
|
|
/**
|
|
* Create a new walker that starts at |startingAt| and with |startingWith|.
|
|
* The instance of |startingWith| will be the one that the walker holds and
|
|
* uses for its life. No one else should reference it.
|
|
*
|
|
* The per-instance behaviour for the walker is provided via |implementation|
|
|
* which is used to connect this walker with the "outside world".
|
|
*
|
|
* @param {shaka.routing.Node} startingAt
|
|
* @param {shaka.routing.Payload} startingWith
|
|
* @param {shaka.routing.Walker.Implementation} implementation
|
|
*/
|
|
constructor(startingAt, startingWith, implementation) {
|
|
/** @private {?shaka.routing.Walker.Implementation} */
|
|
this.implementation_ = implementation;
|
|
|
|
/** @private {shaka.routing.Node} */
|
|
this.currentlyAt_ = startingAt;
|
|
|
|
/** @private {shaka.routing.Payload} */
|
|
this.currentlyWith_ = startingWith;
|
|
|
|
/**
|
|
* When we run out of work to do, we will set this promise so that when
|
|
* new work is added (and this is not null) it can be resolved. The only
|
|
* time when this should be non-null is when we are waiting for more work.
|
|
*
|
|
* @private {?shaka.util.PublicPromise}
|
|
*/
|
|
this.waitForWork_ = null;
|
|
|
|
/** @private {!Array.<shaka.routing.Walker.Request_>} */
|
|
this.requests_ = [];
|
|
|
|
/** @private {?shaka.routing.Walker.ActiveRoute_} */
|
|
this.currentRoute_ = null;
|
|
|
|
/** @private {?shaka.util.AbortableOperation} */
|
|
this.currentStep_ = null;
|
|
|
|
/**
|
|
* Hold a reference to the main loop's promise so that we know when it has
|
|
* exited. This will determine when |destroy| can resolve. Purposely make
|
|
* the main loop start next interpreter cycle so that the constructor will
|
|
* finish before it starts.
|
|
*
|
|
* @private {!Promise}
|
|
*/
|
|
this.mainLoopPromise_ = Promise.resolve().then(() => this.mainLoop_());
|
|
|
|
/** @private {!shaka.util.Destroyer} */
|
|
this.destroyer_ = new shaka.util.Destroyer(() => this.doDestroy_());
|
|
}
|
|
|
|
/**
|
|
* Get the current routing payload.
|
|
*
|
|
* @return {shaka.routing.Payload}
|
|
*/
|
|
getCurrentPayload() {
|
|
return this.currentlyWith_;
|
|
}
|
|
|
|
/** @override */
|
|
destroy() {
|
|
return this.destroyer_.destroy();
|
|
}
|
|
|
|
/** @private */
|
|
async doDestroy_() {
|
|
// If we are executing a current step, we want to interrupt it so that we
|
|
// can force the main loop to terminate.
|
|
if (this.currentStep_) {
|
|
this.currentStep_.abort();
|
|
}
|
|
|
|
// If we are waiting for more work, we want to wake-up the main loop so that
|
|
// it can exit on its own.
|
|
this.unblockMainLoop_();
|
|
|
|
// Wait for the main loop to terminate so that an async operation won't
|
|
// try and use state that we released.
|
|
await this.mainLoopPromise_;
|
|
|
|
// Any routes that we are not going to finish, we need to cancel. If we
|
|
// don't do this, those listening will be left hanging.
|
|
if (this.currentRoute_) {
|
|
this.currentRoute_.listeners.onCancel();
|
|
}
|
|
for (const request of this.requests_) {
|
|
request.listeners.onCancel();
|
|
}
|
|
|
|
// Release anything that could hold references to anything outside of this
|
|
// class.
|
|
this.currentRoute_ = null;
|
|
this.requests_ = [];
|
|
this.implementation_ = null;
|
|
}
|
|
|
|
/**
|
|
* Ask the walker to start a new route. When the walker is ready to start a
|
|
* new route, it will call |create| and |create| will provide the walker with
|
|
* a new route to execute.
|
|
*
|
|
* If any previous calls to |startNewRoute| created non-interruptible routes,
|
|
* |create| won't be called until all previous non-interruptible routes have
|
|
* finished.
|
|
*
|
|
* This method will return a collection of listeners that the caller can hook
|
|
* into. Any listener that the caller is interested should be assigned
|
|
* immediately after calling |startNewRoute| or else they could miss the event
|
|
* they want to listen for.
|
|
*
|
|
* @param {function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
|
|
* @return {shaka.routing.Walker.Listeners}
|
|
*/
|
|
startNewRoute(create) {
|
|
const listeners = {
|
|
onStart: () => {},
|
|
onEnd: () => {},
|
|
onCancel: () => {},
|
|
onError: (error) => {},
|
|
onSkip: () => {},
|
|
onEnter: () => {},
|
|
};
|
|
|
|
this.requests_.push({
|
|
create: create,
|
|
listeners: listeners,
|
|
});
|
|
|
|
// If we are in the middle of a step, try to abort it. If this is successful
|
|
// the main loop will error and the walker will enter recovery mode.
|
|
if (this.currentStep_) {
|
|
this.currentStep_.abort();
|
|
}
|
|
|
|
// Tell the main loop that new work is available. If the main loop was not
|
|
// blocked, this will be a no-op.
|
|
this.unblockMainLoop_();
|
|
|
|
return listeners;
|
|
}
|
|
|
|
/**
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
async mainLoop_() {
|
|
while (!this.destroyer_.destroyed()) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await this.doOneThing_();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Do one thing to move the walker closer to its destination. This can be:
|
|
* 1. Starting a new route.
|
|
* 2. Taking one more step/finishing a route.
|
|
* 3. Wait for a new route.
|
|
*
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
doOneThing_() {
|
|
if (this.tryNewRoute_()) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
if (this.currentRoute_) {
|
|
return this.takeNextStep_();
|
|
}
|
|
|
|
goog.asserts.assert(this.waitForWork_ == null,
|
|
'We should not have a promise yet.');
|
|
|
|
// We have no more work to do. We will wait until new work has been provided
|
|
// via request route or until we are destroyed.
|
|
|
|
this.implementation_.onIdle(this.currentlyAt_);
|
|
|
|
// Wait on a new promise so that we can be resolved by |waitForWork|. This
|
|
// avoids us acting like a busy-wait.
|
|
this.waitForWork_ = new shaka.util.PublicPromise();
|
|
return this.waitForWork_;
|
|
}
|
|
|
|
/**
|
|
* Check if the walker can start a new route. There are a couple ways this can
|
|
* happen:
|
|
* 1. We have a new request but no current route
|
|
* 2. We have a new request and our current route can be interrupted
|
|
*
|
|
* @return {boolean}
|
|
* |true| when a new route was started (regardless of reason) and |false|
|
|
* when no new route was started.
|
|
*
|
|
* @private
|
|
*/
|
|
tryNewRoute_() {
|
|
goog.asserts.assert(
|
|
this.currentStep_ == null,
|
|
'We should never have a current step between taking steps.');
|
|
|
|
if (this.requests_.length == 0) {
|
|
return false;
|
|
}
|
|
|
|
// If the current route cannot be interrupted, we can't start a new route.
|
|
if (this.currentRoute_ && !this.currentRoute_.interruptible) {
|
|
return false;
|
|
}
|
|
|
|
// Stop any previously active routes. Even if we don't pick-up a new route,
|
|
// this route should stop.
|
|
if (this.currentRoute_) {
|
|
this.currentRoute_.listeners.onCancel();
|
|
this.currentRoute_ = null;
|
|
}
|
|
|
|
// Create and start the next route. We may not take any steps because it may
|
|
// be interrupted by the next request.
|
|
const request = this.requests_.shift();
|
|
const newRoute = request.create(this.currentlyWith_);
|
|
|
|
// Based on the current state of |payload|, a new route may not be
|
|
// possible. In these cases |create| will return |null| to signal that
|
|
// we should just stop the current route and move onto the next request
|
|
// (in the next main loop iteration).
|
|
if (newRoute) {
|
|
request.listeners.onStart();
|
|
|
|
// Convert the route created from the request's create method to an
|
|
// active route.
|
|
this.currentRoute_ = {
|
|
node: newRoute.node,
|
|
payload: newRoute.payload,
|
|
interruptible: newRoute.interruptible,
|
|
listeners: request.listeners,
|
|
};
|
|
} else {
|
|
request.listeners.onSkip();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* Move forward one step on our current route. This assumes that we have a
|
|
* current route. A couple things can happen when moving forward:
|
|
* 1. An error - if an error occurs, it will signal an error occurred,
|
|
* attempt to recover, and drop the route.
|
|
* 2. Move - if no error occurs, we will move forward. When we arrive at
|
|
* our destination, it will signal the end and drop the route.
|
|
*
|
|
* In the event of an error or arriving at the destination, we drop the
|
|
* current route. This allows us to pick-up a new route next time the main
|
|
* loop iterates.
|
|
*
|
|
* @return {!Promise}
|
|
* @private
|
|
*/
|
|
async takeNextStep_() {
|
|
goog.asserts.assert(
|
|
this.currentRoute_,
|
|
'We need a current route to take the next step.');
|
|
|
|
// Figure out where we are supposed to go next.
|
|
this.currentlyAt_ = this.implementation_.getNext(
|
|
this.currentlyAt_,
|
|
this.currentlyWith_,
|
|
this.currentRoute_.node,
|
|
this.currentRoute_.payload);
|
|
|
|
this.currentRoute_.listeners.onEnter(this.currentlyAt_);
|
|
|
|
// Enter the new node, this is where things can go wrong since it is
|
|
// possible for "supported errors" to occur - errors that the code using
|
|
// the walker can't predict but can recover from.
|
|
try {
|
|
// TODO: This is probably a false-positive. See eslint/eslint#11687.
|
|
// eslint-disable-next-line require-atomic-updates
|
|
this.currentStep_ = this.implementation_.enterNode(
|
|
/* node= */ this.currentlyAt_,
|
|
/* has= */ this.currentlyWith_,
|
|
/* wants= */ this.currentRoute_.payload);
|
|
|
|
await this.currentStep_.promise;
|
|
this.currentStep_ = null;
|
|
|
|
// If we are at the end of the route, we need to signal it and clear the
|
|
// route so that we will pick-up a new route next iteration.
|
|
if (this.currentlyAt_ == this.currentRoute_.node) {
|
|
this.currentRoute_.listeners.onEnd();
|
|
this.currentRoute_ = null;
|
|
}
|
|
} catch (error) {
|
|
if (error.code == shaka.util.Error.Code.OPERATION_ABORTED) {
|
|
goog.asserts.assert(
|
|
this.currentRoute_.interruptible,
|
|
'Do not put abortable steps in non-interruptible routes!');
|
|
this.currentRoute_.listeners.onCancel();
|
|
} else {
|
|
// There was an error with this route, so we going to abandon it and
|
|
// resolve the error. We don't reset the payload because the payload may
|
|
// still contain useful information.
|
|
this.currentRoute_.listeners.onError(error);
|
|
}
|
|
|
|
// The route and step are done. Clear them before we handle the error or
|
|
// else we may attempt to abort |currentStep_| when handling the error.
|
|
this.currentRoute_ = null;
|
|
this.currentStep_ = null;
|
|
|
|
// Still need to handle error because aborting an operation could leave us
|
|
// in an unexpected state.
|
|
this.currentlyAt_ = await this.implementation_.handleError(
|
|
this.currentlyWith_,
|
|
error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If the main loop is blocked waiting for new work, then resolve the promise
|
|
* so that the next iteration of the main loop can execute.
|
|
*
|
|
* @private
|
|
*/
|
|
unblockMainLoop_() {
|
|
if (this.waitForWork_) {
|
|
this.waitForWork_.resolve();
|
|
this.waitForWork_ = null;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @typedef {{
|
|
* getNext: function(
|
|
* shaka.routing.Node,
|
|
* shaka.routing.Payload,
|
|
* shaka.routing.Node,
|
|
* shaka.routing.Payload):shaka.routing.Node,
|
|
* enterNode: function(
|
|
* shaka.routing.Node,
|
|
* shaka.routing.Payload,
|
|
* shaka.routing.Payload):!shaka.util.AbortableOperation,
|
|
* handleError: function(
|
|
* shaka.routing.Payload,
|
|
* !Error):!Promise.<shaka.routing.Node>,
|
|
* onIdle: function(shaka.routing.Node)
|
|
* }}
|
|
*
|
|
* @description
|
|
* There are some parts of the walker that will be per-instance. This type
|
|
* provides those per-instance parts.
|
|
*
|
|
* @property {function(
|
|
* shaka.routing.Node,
|
|
* shaka.routing.Payload,
|
|
* shaka.routing.Node,
|
|
* shaka.routing.Payload):shaka.routing.Node getNext
|
|
* Get the next node that the walker should move to. This method will be
|
|
* passed (in this order) the current node, current payload, destination
|
|
* node, and destination payload.
|
|
*
|
|
* @property {function(
|
|
* shaka.routing.Node,
|
|
* shaka.routing.Payload,
|
|
* shaka.routing.Payload):!Promise} enterNode
|
|
* When the walker moves into a node, it will call |enterNode| and allow the
|
|
* implementation to change the current payload. This method will be passed
|
|
* (in this order) the node the walker is entering, the current payload, and
|
|
* the destination payload. This method should NOT modify the destination
|
|
* payload.
|
|
*
|
|
* @property {function(
|
|
* shaka.routing.Payload,
|
|
* !Error):!Promise.<shaka.routing.Node> handleError
|
|
* This is the callback for when |enterNode| fails. It is passed the current
|
|
* payload and the error. If a step is aborted, the error will be
|
|
* OPERATION_ABORTED. It should reset all external dependences, modify the
|
|
* payload, and return the new current node. Calls to |handleError| should
|
|
* always resolve and the walker should always be able to continue operating.
|
|
*
|
|
* @property {function(shaka.routing.Node)} onIdle
|
|
* This is the callback for when the walker has finished processing all route
|
|
* requests and needs to wait for more work. |onIdle| will be passed the
|
|
* current node. After |onIdle| has been called, the walker will block until
|
|
* a new request is made, or the walker is destroyed.
|
|
*/
|
|
shaka.routing.Walker.Implementation;
|
|
|
|
/**
|
|
* @typedef {{
|
|
* onStart: function(),
|
|
* onEnd: function(),
|
|
* onCancel: function(),
|
|
* onError: function(!Error),
|
|
* onSkip: function(),
|
|
* onEnter: function(shaka.routing.Node)
|
|
* }}
|
|
*
|
|
* @description
|
|
* The collection of callbacks that the walker will call while executing a
|
|
* route. By setting these immediately after calling |startNewRoute|
|
|
* the user can react to route-specific events.
|
|
*
|
|
* @property {function()} onStart
|
|
* The callback for when the walker has accepted the route and will soon take
|
|
* the first step unless interrupted. Either |onStart| or |onSkip| will be
|
|
* called.
|
|
*
|
|
* @property {function()} onEnd
|
|
* The callback for when the walker has reached the end of the route. For
|
|
* every route that had |onStart| called, either |onEnd|, |onCancel|, or
|
|
* |onError| will be called.
|
|
*
|
|
* @property {function()} onCancel
|
|
* The callback for when the walker is stopping a route before getting to the
|
|
* end. This will be called either when a new route is interrupting the route,
|
|
* or the walker is being destroyed mid-route. |onCancel| will only be called
|
|
* when a route has been interrupted by another route or the walker is being
|
|
* destroyed.
|
|
*
|
|
* @property {function()} onError
|
|
* The callback for when the walker failed to execute the route because an
|
|
* unexpected error occurred. The walker will enter a recovery mode and the
|
|
* route will be abandoned.
|
|
*
|
|
* @property {function()} onSkip
|
|
* The callback for when the walker was ready to start the route, but the
|
|
* create-method returned |null|.
|
|
*
|
|
* @property {function()} onEnter
|
|
* The callback for when the walker enters a node. This will allow us to
|
|
* track the progress of the walker within a per-route scope.
|
|
*/
|
|
shaka.routing.Walker.Listeners;
|
|
|
|
/**
|
|
* @typedef {{
|
|
* node: shaka.routing.Node,
|
|
* payload: shaka.routing.Payload,
|
|
* interruptible: boolean
|
|
* }}
|
|
*
|
|
* @description
|
|
* The public description of where the walker should go. This is created
|
|
* when the callback given to |startNewRoute| is called by the walker.
|
|
*
|
|
* @property {shaka.routing.Node} node
|
|
* The node that the walker should move towards. This will be passed to
|
|
* |shaka.routing.Walker.Implementation.getNext| to help determine where to
|
|
* go next.
|
|
*
|
|
* @property {shaka.routing.Payload| payload
|
|
* The payload that the walker should have once it arrives at |node|. This
|
|
* will be passed to the |shaka.routing.Walker.Implementation.getNext| to
|
|
* help determine where to go next.
|
|
*
|
|
* @property {boolean} interruptible
|
|
* Whether or not this route can be interrupted by another request. When
|
|
* |true| this route will be interrupted so that a pending request can be
|
|
* resolved. When |false|, the route will be allowed to finished before
|
|
* resolving the next request.
|
|
*/
|
|
shaka.routing.Walker.Route;
|
|
|
|
/**
|
|
* @typedef {{
|
|
* node: shaka.routing.Node,
|
|
* payload: shaka.routing.Payload,
|
|
* interruptible: boolean,
|
|
* listeners: shaka.routing.Walker.Listeners
|
|
* }}
|
|
*
|
|
* @description
|
|
* The active route is the walker's internal representation of a route. It
|
|
* is the union of |shaka.routing.Walker.Request_| and the
|
|
* |shaka.routing.Walker.Route| created by |shaka.routing.Walker.Request_|.
|
|
*
|
|
* @property {shaka.routing.Node} node
|
|
* The node that the walker should move towards. This will be passed to
|
|
* |shaka.routing.Walker.Implementation.getNext| to help determine where to
|
|
* go next.
|
|
*
|
|
* @property {shaka.routing.Payload| payload
|
|
* The payload that the walker should have once it arrives at |node|. This
|
|
* will be passed to the |shaka.routing.Walker.Implementation.getNext| to
|
|
* help determine where to go next.
|
|
*
|
|
* @property {boolean} interruptible
|
|
* Whether or not this route can be interrupted by another request. When
|
|
* |true| this route will be interrupted so that a pending request can be
|
|
* resolved. When |false|, the route will be allowed to finished before
|
|
* resolving the next request.
|
|
*
|
|
* @property {shaka.routing.Walker.Listeners} listeners
|
|
* The listeners that the walker can used to communicate with whoever
|
|
* requested the route.
|
|
*
|
|
* @private
|
|
*/
|
|
shaka.routing.Walker.ActiveRoute_;
|
|
|
|
/**
|
|
* @typedef {{
|
|
* create: function(shaka.routing.Payload):?shaka.routing.Walker.Route,
|
|
* listeners: shaka.routing.Walker.Listeners
|
|
* }}
|
|
*
|
|
* @description
|
|
* The request is how users can talk to the walker. They can give the walker
|
|
* a request and when the walker is ready, it will resolve the request by
|
|
* calling |create|.
|
|
*
|
|
* @property {
|
|
* function(shaka.routing.Payload):?shaka.routing.Walker.Route} create
|
|
* The function called when the walker is ready to start a new route. This can
|
|
* return |null| to say that the request was not possible and should be
|
|
* skipped.
|
|
*
|
|
* @property {shaka.routing.Walker.Listeners} listeners
|
|
* The collection of callbacks that the walker will use to talk to whoever
|
|
* provided the request.
|
|
*
|
|
* @private
|
|
*/
|
|
shaka.routing.Walker.Request_;
|