/** * 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 type { IMediaElement, IMediaKeys, IMediaKeySystemAccess, } from "../../../compat/browser_compatibility_types"; import type { IEmeApiImplementation } from "../../../compat/eme"; import { setMediaKeys } from "../../../compat/eme/set_media_keys"; import { EncryptedMediaError } from "../../../errors"; import log from "../../../log"; import type { IKeySystemOption } from "../../../public_types"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import type LoadedSessionsStore from "./loaded_sessions_store"; /** DRM-related state that can be associated to a single HTMLMediaElement. */ export interface IMediaElementMediaKeysInfos { emeImplementation: IEmeApiImplementation; /** Last keySystemOptions used with that HTMLMediaElement. */ keySystemOptions: IKeySystemOption; /** * The actual MediaKeySystemConfiguration asked to the * `requestMediaKeySystemAccess` API. */ askedConfiguration: MediaKeySystemConfiguration; /** * Last MediaKeySystemAccess used to create a MediaKeys bound to that * HTMLMediaElement. */ mediaKeySystemAccess: IMediaKeySystemAccess; /** Last MediaKeys instance bound to that HTMLMediaElement. */ mediaKeys: IMediaKeys; /** * Store containing information about every MediaKeySession active on the * MediaKeys instance bound to that HTMLMediaElement. */ loadedSessionsStore: LoadedSessionsStore; } // Store the MediaKeys infos attached to a media element. const currentMediaState = new WeakMap(); interface IMediaKeysAttacherItem { /** * Information on `MediaKeys` which has been attached or is being attached * to the `HTMLMediaElement`. * * `null` either if no `MediaKeys` is attached or if the last attachment * task failed. */ mediaKeysState: IMediaElementMediaKeysInfos | null; /** * Property set to a Promise **only** when the `MediaKeys` attachment is * pending. * * This promise resolves once `mediaKeys` is attached to that * `HTMLMediaElement`, and rejects if it failed to do so. * * `null` if no `MediaKeys` attachment is pending (it is already attached * or it already failed to do so). */ pendingTask: Promise | null; } export default { /** * Attach new MediaKeys infos set on a HMTLMediaElement. * @param {HTMLMediaElement} mediaElement * @param {Object} mediaKeysInfo * @returns {Promise} */ async attach( mediaElement: IMediaElement, mediaKeysInfo: IMediaElementMediaKeysInfos, ): Promise { const previousState = currentMediaState.get(mediaElement); const pendingTask = attachMediaKeys(mediaElement, previousState, mediaKeysInfo).then( () => { currentMediaState.set(mediaElement, { pendingTask: null, mediaKeysState: mediaKeysInfo, }); }, () => { currentMediaState.set(mediaElement, { pendingTask: null, mediaKeysState: null, }); }, ); currentMediaState.set(mediaElement, { pendingTask, mediaKeysState: mediaKeysInfo, }); return pendingTask; }, /** * Get MediaKeys information expected to be linked to the given * `HTMLMediaElement`. * * Unlike `getAttachedMediaKeysState`, this method is synchronous and will * also return the expected state when `MediaKeys` attachment is still * pending and thus when that state is not truly applied (and where it * might fail before being applied). * * As such, only call this method if you want the currently expected state, * not the actual one. * @param {HTMLMediaElement} mediaElement * @returns {Array} */ getAwaitedState(mediaElement: IMediaElement): IMediaElementMediaKeysInfos | null { const currentState = currentMediaState.get(mediaElement); return currentState?.mediaKeysState ?? null; }, /** * Get MediaKeys information set on a HMTLMediaElement. * * This method is asynchronous because that state may still be in a process * of being attached to the `HTMLMediaElement` (and the state we're * currently setting may not work out). * @param {HTMLMediaElement} mediaElement * @returns {Object|null} */ async getAttachedMediaKeysState( mediaElement: IMediaElement, ): Promise { const currentState = currentMediaState.get(mediaElement); if (currentState === undefined) { return null; } if (currentState.pendingTask !== null) { await currentState.pendingTask; return this.getAttachedMediaKeysState(mediaElement); } return currentState.mediaKeysState; }, /** * Remove MediaKeys currently set on a HMTLMediaElement and update state * accordingly. * @param {HTMLMediaElement} mediaElement * @returns {Promise} */ clearMediaKeys(mediaElement: IMediaElement): Promise { const previousState = currentMediaState.get(mediaElement); const pendingTask = clearMediaKeys(mediaElement, previousState).then( () => { currentMediaState.set(mediaElement, { pendingTask: null, mediaKeysState: null, }); }, () => { currentMediaState.set(mediaElement, { pendingTask: null, mediaKeysState: null, }); }, ); currentMediaState.set(mediaElement, { pendingTask, mediaKeysState: null, }); return pendingTask; }, }; /** * Ensure that the last `MediaKeys` set on the given HTMLMediaElement is * attached. * * The returned Promise never rejects, it will just log an error if the * previous attachment failed. * * @param {Object} previousState * @returns {Promise.} */ async function awaitMediaKeysAttachment( previousState: IMediaKeysAttacherItem, ): Promise { const promise = previousState.pendingTask; if (isNullOrUndefined(promise)) { return; } log.info("DRM", "Awaiting previous MediaKeys attachment operation"); try { await previousState.pendingTask; } catch (err) { log.info( "DRM", "previous MediaKeys attachment operation failed", err instanceof Error ? err : "Unknown error", ); } } async function attachMediaKeys( mediaElement: IMediaElement, previousState: IMediaKeysAttacherItem | undefined, mediaKeysInfo: IMediaElementMediaKeysInfos, ): Promise { if (previousState !== undefined) { if (previousState.pendingTask !== null) { // Ensure the `MediaKeys` has been fully attached to the HTMLMediaElement before // resetting things, to avoid browser errors due to an invalid state. await awaitMediaKeysAttachment(previousState); } const closeAllSessions = !isNullOrUndefined(previousState.mediaKeysState) && previousState.mediaKeysState.loadedSessionsStore !== mediaKeysInfo.loadedSessionsStore ? previousState.mediaKeysState.loadedSessionsStore.closeAllSessions() : Promise.resolve(); await closeAllSessions; if (mediaElement.mediaKeys === mediaKeysInfo.mediaKeys) { log.debug("DRM", "Right MediaKeys already set"); return; } } log.info("DRM", "Attaching MediaKeys to the media element"); try { await setMediaKeys( mediaKeysInfo.emeImplementation, mediaElement, mediaKeysInfo.mediaKeys, ); log.info("DRM", "MediaKeys attached with success"); } catch (err) { const errMessage = err instanceof Error ? err.toString() : "Unknown Error"; throw new EncryptedMediaError( "MEDIA_KEYS_ATTACHMENT_ERROR", "Could not attach the MediaKeys to the media element: " + errMessage, { keyStatuses: undefined, keySystemConfiguration: mediaKeysInfo.mediaKeySystemAccess.getConfiguration(), keySystem: mediaKeysInfo.mediaKeySystemAccess.keySystem, }, ); } } async function clearMediaKeys( mediaElement: IMediaElement, previousState: IMediaKeysAttacherItem | undefined, ): Promise { if (previousState === undefined) { return; } if (previousState.pendingTask !== null) { // Ensure the `MediaKeys` has been fully attached to the HTMLMediaElement before // resetting things, to avoid browser errors due to an invalid state. await awaitMediaKeysAttachment(previousState); } if (previousState.mediaKeysState === null) { return; } log.info("DRM", "Disposing of the current MediaKeys"); const { loadedSessionsStore } = previousState.mediaKeysState; await loadedSessionsStore.closeAllSessions(); return setMediaKeys(previousState.mediaKeysState.emeImplementation, mediaElement, null); }