rx-player
Version:
Canal+ HTML5 Video Player
379 lines (353 loc) • 12.8 kB
text/typescript
/**
* Copyright 2015 CANAL+ Group
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* /!\ This file is feature-switchable.
* It always should be imported through the `features` object.
*/
import type { IMediaElement } from "../../compat/browser_compatibility_types";
import clearElementSrc from "../../compat/clear_element_src";
import type { MediaError } from "../../errors";
import log from "../../log";
import type { IMediaElementPlaybackObserver } from "../../playback_observer";
import type { IKeySystemOption, IPlayerError } from "../../public_types";
import assert from "../../utils/assert";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import noop from "../../utils/noop";
import type { IReadOnlySharedReference } from "../../utils/reference";
import type { CancellationSignal } from "../../utils/task_canceller";
import TaskCanceller from "../../utils/task_canceller";
import { ContentInitializer } from "./types";
import type { IInitialTimeOptions } from "./utils/get_initial_time";
import getLoadedReference from "./utils/get_loaded_reference";
import performInitialSeekAndPlay from "./utils/initial_seek_and_play";
import initializeContentDecryption from "./utils/initialize_content_decryption";
import RebufferingController from "./utils/rebuffering_controller";
import listenToMediaError from "./utils/throw_on_media_error";
/**
* `ContentIntializer` which will load contents by putting their URL in the
* `src` attribute of the given HTMLMediaElement.
*
* Because such contents are mainly loaded by the browser, those (called
* "directfile" contents in the RxPlayer) needs a simpler logic in-JS when
* compared to a content that relies on the MSE API.
*
* @class DirectFileContentInitializer
*/
export default class DirectFileContentInitializer extends ContentInitializer {
/**
* Initial options given to the `DirectFileContentInitializer`.
*/
private _settings: IDirectFileOptions;
/**
* Allows to abort and clean everything the `DirectFileContentInitializer` is
* doing.
*/
private _initCanceller: TaskCanceller;
/**
* Creates a new `DirectFileContentInitializer` linked to the given settings.
* @param {Object} settings
*/
constructor(settings: IDirectFileOptions) {
super();
this._settings = settings;
this._initCanceller = new TaskCanceller();
}
/**
* "Prepare" content so it can later be played by calling `start`.
*/
public prepare(): void {
return; // Directfile contents do not have any preparation
}
/**
* Start playback of the content linked to this `DirectFileContentInitializer`
* on the given `HTMLMediaElement` and its associated `PlaybackObserver`.
* @param {HTMLMediaElement} mediaElement - HTMLMediaElement on which the
* content will be played.
* @param {Object} playbackObserver - Object regularly emitting playback
* information.
*/
public start(
mediaElement: IMediaElement,
playbackObserver: IMediaElementPlaybackObserver,
): void {
const cancelSignal = this._initCanceller.signal;
const { keySystems, speed, url } = this._settings;
clearElementSrc(mediaElement);
// Set the autoplay attribute on the mediaElement.
// On Apple devices, the native HLS player needs autoplay to be set
// in order to start buffering,which is required for our API's autoplay to work.
setAutoplay(mediaElement, this._settings.autoPlay, cancelSignal);
const { statusRef: drmInitRef } = initializeContentDecryption(
mediaElement,
keySystems,
{
onError: (err) => this._onFatalError(err),
onWarning: (err: IPlayerError) => this.trigger("warning", err),
onBlackListProtectionData: noop,
onKeyIdsCompatibilityUpdate: noop,
},
cancelSignal,
);
/** Translate errors coming from the media element into RxPlayer errors. */
listenToMediaError(
mediaElement,
(error: MediaError) => this._onFatalError(error),
cancelSignal,
);
/**
* Class trying to avoid various stalling situations, emitting "stalled"
* events when it cannot, as well as "unstalled" events when it get out of one.
*/
const rebufferingController = new RebufferingController(
playbackObserver,
null,
speed,
);
rebufferingController.addEventListener("stalled", (evt) =>
this.trigger("stalled", evt),
);
rebufferingController.addEventListener("unstalled", () =>
this.trigger("unstalled", null),
);
rebufferingController.addEventListener("warning", (err) =>
this.trigger("warning", err),
);
cancelSignal.register(() => {
rebufferingController.destroy();
});
rebufferingController.start();
drmInitRef.onUpdate(
(evt, stopListeningToDrmUpdates) => {
if (evt.initializationState.type === "uninitialized") {
return; // nothing done yet
}
stopListeningToDrmUpdates();
// Start everything! (Just put the URL in the element's src).
log.info("Init", "Setting URL to HTMLMediaElement", { url });
mediaElement.src = url;
cancelSignal.register(() => {
log.info("Init", "Removing directfile src from media element", {
src: mediaElement.src,
});
clearElementSrc(mediaElement);
});
if (evt.initializationState.type === "awaiting-media-link") {
evt.initializationState.value.isMediaLinked.setValue(true);
drmInitRef.onUpdate(
(newDrmStatus, stopListeningToDrmUpdatesAgain) => {
if (newDrmStatus.initializationState.type === "initialized") {
stopListeningToDrmUpdatesAgain();
this._seekAndPlay(mediaElement, playbackObserver);
}
},
{ emitCurrentValue: true, clearSignal: cancelSignal },
);
} else {
assert(evt.initializationState.type === "initialized");
this._seekAndPlay(mediaElement, playbackObserver);
}
},
{ emitCurrentValue: true, clearSignal: cancelSignal },
);
}
/**
* Update URL this `ContentIntializer` depends on.
* @param {Array.<string>|undefined} _urls
* @param {boolean} _refreshNow
*/
public updateContentUrls(_urls: string[] | undefined, _refreshNow: boolean): void {
throw new Error("Cannot update content URL of directfile contents");
}
/**
* Stop content and free all resources linked to this `ContentIntializer`.
*/
public dispose(): void {
this._initCanceller.cancel();
}
/**
* Logic performed when a fatal error was triggered.
* @param {*} err - The fatal error in question.
*/
private _onFatalError(err: unknown): void {
this._initCanceller.cancel();
this.trigger("error", err);
}
/**
* Perform the initial seek (to begin playback at an initially-calculated
* position based on settings) and auto-play if needed when loaded.
* @param {HTMLMediaElement} mediaElement
* @param {Object} playbackObserver
*/
private _seekAndPlay(
mediaElement: IMediaElement,
playbackObserver: IMediaElementPlaybackObserver,
): void {
const cancelSignal = this._initCanceller.signal;
const { autoPlay, startAt } = this._settings;
const initialTime = () => {
log.debug("Init", "Calculating initial time");
const initTime = getDirectFileInitialTime(mediaElement, startAt);
log.debug("Init", "Initial time calculated", { initialTime: initTime });
return initTime;
};
performInitialSeekAndPlay(
{
mediaElement,
playbackObserver,
startTime: initialTime,
mustAutoPlay: autoPlay,
onWarning: (err) => this.trigger("warning", err),
isDirectfile: true,
},
cancelSignal,
)
.autoPlayResult.then(() =>
getLoadedReference(playbackObserver, true, cancelSignal).onUpdate(
(isLoaded, stopListening) => {
if (isLoaded) {
stopListening();
this.trigger("loaded", {
getSegmentSinkMetrics: null,
getThumbnailData: () =>
Promise.reject(
new Error("Thumbnail data not available with directfile contents"),
),
});
}
},
{ emitCurrentValue: true, clearSignal: cancelSignal },
),
)
.catch((err) => {
if (!cancelSignal.isCancelled()) {
this._onFatalError(err);
}
});
}
}
/**
* Set autoplay value on the mediaElement.
*
* @param {HTMLElement} mediaElement - The media element whose `autoplay`
* attribute will be modified.
* @param {CancellationSignal} cancellationSignal - The signal that, when triggered,
* restores the `autoplay` attribute to its original value.
*/
export function setAutoplay(
mediaElement: IMediaElement,
autoplay: boolean,
cancellationSignal: CancellationSignal,
) {
if (!autoplay) {
// If autoplay option is set to false, don't touch to `autoplay`
// videoElement attribute.
return;
}
const autoplayPreviousValue = mediaElement.autoplay;
mediaElement.autoplay = autoplay;
cancellationSignal.register(() => {
/**
* Restore the `autoplay` attribute to its previous value.
* This ensures that the media element's state is the same as it was before
* calling `RxPlayer.loadVideo` in the application.
*/
mediaElement.autoplay = autoplayPreviousValue;
});
}
/**
* calculate initial time as a position in seconds.
* @param {HTMLMediaElement} mediaElement
* @param {Object|undefined} [startAt]
* @returns {number}
*/
function getDirectFileInitialTime(
mediaElement: IMediaElement,
startAt?: IInitialTimeOptions,
): number | undefined {
if (isNullOrUndefined(startAt)) {
return 0;
}
if (!isNullOrUndefined(startAt.position)) {
return startAt.position;
} else if (!isNullOrUndefined(startAt.wallClockTime)) {
return startAt.wallClockTime;
} else if (!isNullOrUndefined(startAt.fromFirstPosition)) {
return startAt.fromFirstPosition;
}
const duration = mediaElement.duration;
if (typeof startAt.fromLastPosition === "number") {
if (!isNullOrUndefined(duration) && isFinite(duration)) {
return Math.max(0, duration + startAt.fromLastPosition);
}
if (mediaElement.seekable.length > 0) {
const lastSegmentEnd = mediaElement.seekable.end(mediaElement.seekable.length - 1);
if (isFinite(lastSegmentEnd)) {
return Math.max(0, lastSegmentEnd + startAt.fromLastPosition);
}
}
log.warn(
"Init",
"startAt.fromLastPosition set but duration is not known, " +
"it may be too soon to seek",
);
return undefined;
} else if (typeof startAt.fromLivePosition === "number") {
const livePosition =
mediaElement.seekable.length > 0 ? mediaElement.seekable.end(0) : duration;
if (isNullOrUndefined(livePosition)) {
log.warn(
"Init",
"startAt.fromLivePosition set but live position is not known, " +
"beginning at 0.",
);
return 0;
}
return Math.max(0, livePosition + startAt.fromLivePosition);
} else if (!isNullOrUndefined(startAt.percentage)) {
if (isNullOrUndefined(duration) || !isFinite(duration)) {
log.warn(
"Init",
"startAt.percentage set but duration is not known, " + "beginning at 0.",
);
return 0;
}
const { percentage } = startAt;
if (percentage >= 100) {
return duration;
} else if (percentage <= 0) {
return 0;
}
const ratio = +percentage / 100;
return duration * ratio;
}
return 0;
}
/** Options used by the `DirectFileContentInitializer` */
export interface IDirectFileOptions {
/** If `true` we will play right after the content is considered "loaded". */
autoPlay: boolean;
/**
* Encryption-related settings. Can be left as an empty array if the content
* isn't encrypted.
*/
keySystems: IKeySystemOption[];
/** Communicate the playback rate wanted by the user. */
speed: IReadOnlySharedReference<number>;
/** Optional initial position to start at. */
startAt?: IInitialTimeOptions | undefined;
/** URL that should be played. */
url: string;
}