rx-player
Version:
Canal+ HTML5 Video Player
267 lines (250 loc) • 8.77 kB
text/typescript
import { formatError } from "../errors";
import errorMessage from "../errors/error_message";
import { getPeriodForTime } from "../manifest";
import type { IThumbnailRenderingOptions } from "../public_types";
import type { IThumbnailResponse } from "../transports";
import arrayFind from "../utils/array_find";
import arrayFindIndex from "../utils/array_find_index";
import TaskCanceller from "../utils/task_canceller";
import type { IPublicApiContentInfos } from "./api/public_api";
/**
* Render thumbnail available at `time` in the given `container` (in place of
* a potential previously-rendered thumbnail in that container).
*
* If there is no thumbnail at this time, or if there is but it fails to
* load/render, also removes the previously displayed thumbnail, unless
* `options.keepPreviousThumbnailOnError` is set to `true`.
*
* Returns a Promise which resolves when the thumbnail is rendered successfully,
* rejects if anything prevented a thumbnail to be rendered.
*
* A newer `renderThumbnail` call performed while a previous `renderThumbnail`
* call on the same container did not yet finish will abort that previous call,
* rejecting the old call's returned promise.
*
* You may know if the promise returned by `renderThumbnail` rejected due to it
* being aborted, by checking the `code` property on the rejected error: Error
* due to aborting have their `code` property set to `ABORTED`.
*
* @param {Object} contentInfos
* @param {Object} options
* @returns {Object}
*/
export default async function renderThumbnail(
contentInfos: IPublicApiContentInfos | null,
options: IThumbnailRenderingOptions,
): Promise<void> {
const { time, container } = options;
if (
contentInfos === null ||
contentInfos.fetchThumbnailDataCallback === null ||
contentInfos.manifest === null
) {
return Promise.reject(
new ThumbnailRenderingError(
"NO_CONTENT",
"Cannot get thumbnail: no content loaded",
),
);
}
const { thumbnailRequestsInfo, currentContentCanceller } = contentInfos;
const canceller = new TaskCanceller();
const unlinkCanceller = canceller.linkToSignal(currentContentCanceller.signal);
let imageUrl: string | undefined;
const olderTaskSameContainer = thumbnailRequestsInfo.pendingRequests.get(container);
olderTaskSameContainer?.cancel();
thumbnailRequestsInfo.pendingRequests.set(container, canceller);
const onFinished = () => {
unlinkCanceller();
canceller.cancel();
thumbnailRequestsInfo.pendingRequests.delete(container);
// Let's revoke the URL after a round-trip to the event loop just in case
// to prevent revoking before the browser use it.
// This is normally not necessary, but better safe than sorry.
setTimeout(() => {
if (imageUrl !== undefined) {
URL.revokeObjectURL(imageUrl);
}
}, 0);
};
try {
const period = getPeriodForTime(contentInfos.manifest, time);
if (period === undefined) {
throw new ThumbnailRenderingError("NO_THUMBNAIL", "Wanted Period not found.");
}
const thumbnailTracks = period.thumbnailTracks;
const thumbnailTrack =
options.thumbnailTrackId !== undefined
? arrayFind(thumbnailTracks, (t) => t.id === options.thumbnailTrackId)
: thumbnailTracks[0];
if (thumbnailTrack === undefined) {
if (options.thumbnailTrackId !== undefined) {
throw new ThumbnailRenderingError(
"NO_THUMBNAIL",
"Given `thumbnailTrackId` not found",
);
} else {
throw new ThumbnailRenderingError(
"NO_THUMBNAIL",
"Wanted Period has no thumbnail track.",
);
}
}
const { lastResponse } = thumbnailRequestsInfo;
let res: IThumbnailResponse | undefined;
if (
lastResponse !== null &&
lastResponse.thumbnailTrackId === thumbnailTrack.id &&
lastResponse.periodId === period.id
) {
const previousThumbs = lastResponse.response.thumbnails;
if (
previousThumbs.length > 0 &&
time >= previousThumbs[0].start &&
time < previousThumbs[previousThumbs.length - 1].end
) {
res = lastResponse.response;
}
}
if (res === undefined) {
res = await contentInfos.fetchThumbnailDataCallback(
period.id,
thumbnailTrack.id,
time,
);
thumbnailRequestsInfo.lastResponse = {
response: res,
periodId: period.id,
thumbnailTrackId: thumbnailTrack.id,
};
}
if (canceller.signal.cancellationError !== null) {
throw canceller.signal.cancellationError;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (context === null) {
throw new ThumbnailRenderingError(
"RENDERING",
"Cannot display thumbnail: cannot create canvas context",
);
}
const foundIdx = arrayFindIndex(res.thumbnails, (t) => {
return t.start <= time && t.end > time;
});
if (foundIdx < 0) {
throw new Error("Cannot display thumbnail: time not found in fetched data");
}
const image = new Image();
const blob = new Blob([res.data], { type: res.mimeType });
imageUrl = URL.createObjectURL(blob);
image.src = imageUrl;
canvas.height = res.thumbnails[foundIdx].height;
canvas.width = res.thumbnails[foundIdx].width;
return new Promise((resolve, reject) => {
image.onload = () => {
if (canceller.signal.cancellationError !== null) {
reject(canceller.signal.cancellationError);
onFinished();
return;
}
try {
context.drawImage(
image,
res.thumbnails[foundIdx].offsetX,
res.thumbnails[foundIdx].offsetY,
res.thumbnails[foundIdx].width,
res.thumbnails[foundIdx].height,
0,
0,
res.thumbnails[foundIdx].width,
res.thumbnails[foundIdx].height,
);
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.className = "__rx-thumbnail__";
clearPreviousThumbnails();
container.appendChild(canvas);
resolve();
} catch (srcError) {
reject(
new ThumbnailRenderingError(
"RENDERING",
"Could not draw the image in a canvas:" +
(srcError instanceof Error ? srcError.toString() : "Unknown Error"),
),
);
}
onFinished();
};
image.onerror = () => {
if (canceller.signal.cancellationError !== null) {
reject(canceller.signal.cancellationError);
onFinished();
return;
}
if (options.keepPreviousThumbnailOnError !== true) {
clearPreviousThumbnails();
}
reject(
new ThumbnailRenderingError(
"RENDERING",
"Could not load the corresponding image in the DOM",
),
);
onFinished();
};
});
} catch (srcError) {
if (options.keepPreviousThumbnailOnError !== true) {
clearPreviousThumbnails();
}
if (srcError !== null && srcError === canceller.signal.cancellationError) {
const error = new ThumbnailRenderingError(
"ABORTED",
"Thumbnail rendering has been aborted",
);
throw error;
}
const formattedErr = formatError(srcError, {
defaultCode: "NONE",
defaultReason: "Unknown error",
});
let returnedError;
if (formattedErr.type === "NETWORK_ERROR") {
returnedError = new ThumbnailRenderingError("LOADING", formattedErr.message);
} else {
returnedError = new ThumbnailRenderingError("NOT_FOUND", formattedErr.message);
}
onFinished();
throw returnedError;
}
function clearPreviousThumbnails() {
for (let i = container.children.length - 1; i >= 0; i--) {
const child = container.children[i];
if (child.className === "__rx-thumbnail__") {
container.removeChild(child);
}
}
}
}
/**
* Error specifcically defined for the thumbnail rendering API.
* A caller is then supposed to programatically classify the type of error
* by checking the `code` property from such an error.
* @class ThumbnailRenderingError
*/
class ThumbnailRenderingError extends Error {
public readonly name: "ThumbnailRenderingError";
public readonly code: string;
/**
* @param {string} code
* @param {string} message
*/
constructor(code: string, message: string) {
super(errorMessage(code, message));
Object.setPrototypeOf(this, ThumbnailRenderingError.prototype);
this.name = "ThumbnailRenderingError";
this.code = code;
}
}