rx-player
Version:
Canal+ HTML5 Video Player
447 lines (418 loc) • 15 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.
*/
import config from "../../../config";
import type { ISegmentFetcher } from "../../../core/fetchers/segment/segment_fetcher";
import createSegmentFetcher from "../../../core/fetchers/segment/segment_fetcher";
import log from "../../../log";
import type { IRxPlayer } from "../../../main_thread/types";
import type { ISegment } from "../../../manifest";
import Manifest from "../../../manifest/classes";
import type { MainSourceBufferInterface } from "../../../mse/main_media_source_interface";
import arrayFind from "../../../utils/array_find";
import isNullOrUndefined from "../../../utils/is_null_or_undefined";
import objectAssign from "../../../utils/object_assign";
import TaskCanceller, { CancellationError } from "../../../utils/task_canceller";
import loadAndPushSegment from "./load_and_push_segment";
import prepareSourceBuffer from "./prepare_source_buffer";
import removeBufferAroundTime from "./remove_buffer_around_time";
import type { IContentInfo, ILoaders } from "./types";
import VideoThumbnailLoaderError from "./video_thumbnail_loader_error";
const MIN_NEEDED_DATA_AFTER_TIME = 2;
const loaders: ILoaders = {};
/**
* This tool, as a supplement to the RxPlayer, intent to help creating thumbnails
* from a video source.
*
* The tools will extract a "thumbnail track" either from a video track (whose light
* chunks are adapted from such use case) or direclty from the media content.
*/
export default class VideoThumbnailLoader {
private readonly _videoElement: HTMLVideoElement;
private _player: IRxPlayer;
private _lastRepresentationInfo: IVideoThumbnailLoaderRepresentationInfo | null;
constructor(videoElement: HTMLVideoElement, player: IRxPlayer) {
this._videoElement = videoElement;
this._player = player;
this._lastRepresentationInfo = null;
}
/**
* Add imported loader to thumbnail loader loader object.
* It allows to use it when setting time.
* @param {function} loaderFunc
*/
static addLoader(loaderFunc: (features: ILoaders) => void): void {
loaderFunc(loaders);
}
/**
* Set time of thumbnail video media element :
* - Remove buffer when too much buffered data
* - Search for thumbnail track element to display
* - Load data
* - Append data
* Resolves when time is set.
* @param {number} time
* @returns {Promise}
*/
setTime(time: number): Promise<number> {
// TODO cleaner way to interop than an undocumented method?
const manifest = this._player.__priv_getManifest();
if (manifest === null) {
if (this._lastRepresentationInfo !== null) {
this._lastRepresentationInfo.cleaner.cancel();
this._lastRepresentationInfo = null;
}
return Promise.reject(
new VideoThumbnailLoaderError("NO_MANIFEST", "No manifest available."),
);
}
if (!(manifest instanceof Manifest)) {
throw new Error(
"Impossible to run VideoThumbnailLoader in the current context.\n" +
"Are you running the RxPlayer in a WebWorker?",
);
}
const content = getTrickModeInfo(time, manifest);
if (content === null) {
if (this._lastRepresentationInfo !== null) {
this._lastRepresentationInfo.cleaner.cancel();
this._lastRepresentationInfo = null;
}
return Promise.reject(
new VideoThumbnailLoaderError(
"NO_TRACK",
"Couldn't find a trickmode track for this time.",
),
);
}
if (
this._lastRepresentationInfo !== null &&
!areSameRepresentation(this._lastRepresentationInfo.content, content)
) {
this._lastRepresentationInfo.cleaner.cancel();
this._lastRepresentationInfo = null;
}
const neededSegments = content.representation.index.getSegments(
time,
MIN_NEEDED_DATA_AFTER_TIME,
);
if (neededSegments.length === 0) {
if (this._lastRepresentationInfo !== null) {
this._lastRepresentationInfo.cleaner.cancel();
this._lastRepresentationInfo = null;
}
return Promise.reject(
new VideoThumbnailLoaderError(
"NO_THUMBNAIL",
"Couldn't find any thumbnail for the given time.",
),
);
}
// Check which of `neededSegments` are already buffered
for (let j = 0; j < neededSegments.length; j++) {
const { time: stime, duration, timescale } = neededSegments[j];
const start = stime / timescale;
const end = start + duration / timescale;
for (let i = 0; i < this._videoElement.buffered.length; i++) {
if (
this._videoElement.buffered.start(i) - 0.001 <= start &&
this._videoElement.buffered.end(i) + 0.001 >= end
) {
neededSegments.splice(j, 1);
j--;
break;
}
}
}
if (neededSegments.length === 0) {
this._videoElement.currentTime = time;
log.debug("VideoThumbnailLoader", "Thumbnails already loaded.", time);
return Promise.resolve(time);
}
if (log.hasLevel("DEBUG")) {
log.debug(
"VideoThumbnailLoader",
"Found thumbnail for time",
time,
neededSegments.map((s) => `start: ${s.time} - end: ${s.end}`).join(", "),
);
}
const loader = loaders[content.manifest.transport];
if (loader === undefined) {
if (this._lastRepresentationInfo !== null) {
this._lastRepresentationInfo.cleaner.cancel();
this._lastRepresentationInfo = null;
}
return Promise.reject(
new VideoThumbnailLoaderError(
"NO_LOADER",
"VideoThumbnailLoaderError: No imported loader for this transport type: " +
content.manifest.transport,
),
);
}
let lastRepInfo: IVideoThumbnailLoaderRepresentationInfo;
if (this._lastRepresentationInfo === null) {
const lastRepInfoCleaner = new TaskCanceller();
const segmentFetcher = createSegmentFetcher({
bufferType: "video",
pipeline: loader.video,
cdnPrioritizer: null,
cmcdDataBuilder: null,
requestOptions: {
baseDelay: 0,
maxDelay: 0,
maxRetry: 0,
requestTimeout: config.getCurrent().DEFAULT_REQUEST_TIMEOUT,
connectionTimeout: config.getCurrent().DEFAULT_CONNECTION_TIMEOUT,
},
// We don't care about the SegmentFetcher's lifecycle events
eventListeners: {},
}) as ISegmentFetcher<ArrayBuffer | Uint8Array>;
const initSegment = content.representation.index.getInitSegment();
const initSegmentUniqueId =
initSegment !== null ? content.representation.uniqueId : null;
const sourceBufferProm = prepareSourceBuffer(
this._videoElement,
content.representation.getMimeTypeString(),
lastRepInfoCleaner.signal,
).then(async (sourceBufferInterface) => {
if (initSegment === null || initSegmentUniqueId === null) {
lastRepInfo.initSegmentUniqueId = null;
return sourceBufferInterface;
}
const segmentInfo = objectAssign(
{ segment: initSegment, nextSegment: undefined },
content,
);
await loadAndPushSegment(
segmentInfo,
sourceBufferInterface,
lastRepInfo.segmentFetcher,
lastRepInfoCleaner.signal,
);
return sourceBufferInterface;
});
lastRepInfo = {
cleaner: lastRepInfoCleaner,
sourceBuffer: sourceBufferProm,
content,
initSegmentUniqueId,
segmentFetcher,
pendingRequests: [],
};
this._lastRepresentationInfo = lastRepInfo;
} else {
lastRepInfo = this._lastRepresentationInfo;
}
abortUnlistedSegmentRequests(lastRepInfo.pendingRequests, neededSegments);
const currentTaskCanceller = new TaskCanceller();
return lastRepInfo.sourceBuffer
.catch((err) => {
if (this._lastRepresentationInfo !== null) {
this._lastRepresentationInfo.cleaner.cancel();
this._lastRepresentationInfo = null;
}
throw new VideoThumbnailLoaderError(
"LOADING_ERROR",
"VideoThumbnailLoaderError: Error when initializing buffers: " + String(err),
);
})
.then(async (sourceBufferInterface) => {
abortUnlistedSegmentRequests(lastRepInfo.pendingRequests, neededSegments);
log.debug("VideoThumbnailLoader", "Removing buffer around time.", time);
await removeBufferAroundTime(
this._videoElement,
sourceBufferInterface,
time,
undefined,
);
if (currentTaskCanceller.signal.cancellationError !== null) {
throw currentTaskCanceller.signal.cancellationError;
}
abortUnlistedSegmentRequests(lastRepInfo.pendingRequests, neededSegments);
const promises: Array<Promise<unknown>> = [];
for (const segment of neededSegments) {
const pending = arrayFind(
lastRepInfo.pendingRequests,
({ segmentId }) => segmentId === segment.id,
);
if (pending !== undefined) {
promises.push(pending.promise);
} else {
const requestCanceller = new TaskCanceller();
const unlinkSignal = requestCanceller.linkToSignal(
lastRepInfo.cleaner.signal,
);
const segmentInfo = objectAssign(
{ segment, nextSegment: undefined },
content,
);
const prom = loadAndPushSegment(
segmentInfo,
sourceBufferInterface,
lastRepInfo.segmentFetcher,
requestCanceller.signal,
).then(unlinkSignal, (err) => {
unlinkSignal();
throw err;
});
const newReq = {
segmentId: segment.id,
canceller: requestCanceller,
promise: prom,
};
lastRepInfo.pendingRequests.push(newReq);
const removePendingRequest = () => {
const indexOf = lastRepInfo.pendingRequests.indexOf(newReq);
if (indexOf >= 0) {
lastRepInfo.pendingRequests.splice(indexOf, 1);
}
};
prom.then(removePendingRequest, removePendingRequest);
promises.push(prom);
}
}
await Promise.all(promises);
this._videoElement.currentTime = time;
return time;
})
.catch((err) => {
if (err instanceof CancellationError) {
throw new VideoThumbnailLoaderError(
"ABORTED",
"VideoThumbnailLoaderError: Aborted job.",
);
}
throw err;
});
}
/**
* Dispose thumbnail loader.
* @returns {void}
*/
dispose(): void {
if (this._lastRepresentationInfo !== null) {
this._lastRepresentationInfo.cleaner.cancel();
this._lastRepresentationInfo = null;
}
}
}
/**
* @param {Object} contentInfo1
* @param {Object} contentInfo2
* @returns {Boolean}
*/
function areSameRepresentation(
contentInfo1: IContentInfo,
contentInfo2: IContentInfo,
): boolean {
return (
contentInfo1.representation.id === contentInfo2.representation.id &&
contentInfo1.adaptation.id === contentInfo2.adaptation.id &&
contentInfo1.period.id === contentInfo2.period.id &&
contentInfo1.manifest.id === contentInfo2.manifest.id
);
}
/**
* From a given time, find the trickmode representation and return
* the content information.
* @param {number} time
* @param {Object} manifest
* @returns {Object|null}
*/
function getTrickModeInfo(time: number, manifest: Manifest): IContentInfo | null {
const period = manifest.getPeriodForTime(time);
if (
period === undefined ||
period.adaptations.video === undefined ||
period.adaptations.video.length === 0
) {
return null;
}
for (const videoAdaptation of period.adaptations.video) {
const representation = videoAdaptation.trickModeTracks?.[0].representations?.[0];
if (!isNullOrUndefined(representation)) {
return { manifest, period, adaptation: videoAdaptation, representation };
}
}
return null;
}
function abortUnlistedSegmentRequests(
pendingRequests: IPendingRequestInfo[],
neededSegments: ISegment[],
): void {
pendingRequests
.filter((req) => !neededSegments.some(({ id }) => id === req.segmentId))
.forEach((req) => {
req.canceller.cancel();
});
}
/**
* Object containing information stored by a `VideoThumbnailLoader` linked to
* a single chosen Representation.
*/
interface IVideoThumbnailLoaderRepresentationInfo {
/**
* TaskCanceller allowing on cancellation to Stop everything, free resources
* allocated for the current Manifest and remove MediaSource from the video
* Element the VideoThumbnailLoader is associated with.
*/
cleaner: TaskCanceller;
/**
* Promise encapsulating the task of creating the MediaSource, the video
* `SourceBufferInterface`, and pushing the initialization segment of the
* current content on it.
*
* Resolves when done, rejects if any of those steps fail.
*/
sourceBuffer: Promise<MainSourceBufferInterface>;
/**
* Information on the content considered in this
* `IVideoThumbnailLoaderRepresentationInfo`.
*/
content: IContentInfo;
/**
* `ISegmentFetcher` used to fetch video media segments for the current
* Representation.
*/
segmentFetcher: ISegmentFetcher<ArrayBuffer | Uint8Array>;
/**
* List video media segment requests AND pushing (on the buffer) operations
* that are currently pending.
*
* Once a segment is loaded and pushed with success, it is removed from
* `pendingRequests`.
*/
pendingRequests: IPendingRequestInfo[];
initSegmentUniqueId: string | null;
}
interface IPendingRequestInfo {
/** `id` property of the `ISegment` concerned. */
segmentId: string;
/**
* When this promise resolves, the segment is both loaded and pushed with
* success.
*
* If it rejects, we could not either load or push the segment.
*/
promise: Promise<unknown>;
/**
* Allows to stop loading and/or pushing the segment.
*/
canceller: TaskCanceller;
}
export { default as DASH_LOADER } from "./features/dash";
export { default as MPL_LOADER } from "./features/metaplaylist";