From 0ba1b865d239b29d9fe5ef8b7696cfe888f7b1c3 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 1 Aug 2024 12:09:53 +0200 Subject: [PATCH] [Proposal] DRM: add `failOnEncryptedAfterClear` keySystems option An application reported to us an issue where they couldn't play a content of mixed clear and encrypted contents on PlayReady devices. After a LOT of attempts at work-arounds, some of them described [here](https://github.com/canalplus/rx-player/issues/1403), we didn't succeed to actually find a good solution that would both allow smooth transition between Periods and a mix of encrypted unencrypted content. So I attempted to play the same content with other players: - dash.js didn't had a smooth transition between Periods here, it first loaded and played the clear content, then once finished loaded and played the encrypted content with a black screen and license request in-between. - The shaka-player failed to play the content, I tried to debug it but I did not succeed to make it play the content. This is very probably an issue with PlayReady, yet they historically haven't been quick to fix issues we reported, so we may have to provide a perhaps-temporary solution here. That's why I'm introducing the for-now undocumented (and experimental feature?) `keySystems[].failOnEncryptedAfterClear` `loadVideo` option. When set to `true`, we'll reload if an encrypted Period is encountered after a clear Period has been played. The logic for now is not perfect with very rare risks of false negatives. NOTE: I also profited from this commit to add the `canFilterProtectionData` option to the streams. Previously it was implicitly enabled when the `drmSystemId` was defined. --- src/core/main/worker/worker_main.ts | 26 +++- .../stream/adaptation/adaptation_stream.ts | 1 + src/core/stream/adaptation/types.ts | 8 ++ .../orchestrator/stream_orchestrator.ts | 59 +++++++++ .../representation/representation_stream.ts | 10 +- src/core/stream/representation/types.ts | 8 ++ .../__tests__/__global__/get_license.test.ts | 62 +++++---- .../__tests__/__global__/init_data.test.ts | 18 +-- .../media_key_system_access.test.ts | 27 ++-- .../__tests__/__global__/media_keys.test.ts | 59 +++++---- .../__global__/server_certificate.test.ts | 10 +- .../decrypt/__tests__/__global__/utils.ts | 7 +- src/main_thread/decrypt/content_decryptor.ts | 120 +++++++++-------- src/main_thread/decrypt/types.ts | 69 ++++++++-- .../init/directfile_content_initializer.ts | 10 +- .../init/media_source_content_initializer.ts | 43 ++++-- .../init/multi_thread_content_initializer.ts | 122 ++++++++++-------- .../utils/initialize_content_decryption.ts | 122 ++++++++++-------- .../utils/update_manifest_codec_support.ts | 2 +- src/multithread_types.ts | 13 ++ src/public_types.ts | 6 + 21 files changed, 539 insertions(+), 263 deletions(-) diff --git a/src/core/main/worker/worker_main.ts b/src/core/main/worker/worker_main.ts index c3532e789b..dc49effc8c 100644 --- a/src/core/main/worker/worker_main.ts +++ b/src/core/main/worker/worker_main.ts @@ -463,6 +463,19 @@ interface IBufferingInitializationInformation { * optimizations. */ drmSystemId: string | undefined; + /** + * If `true`, protection data as found in the content can be manipulated so + * e.g. only the data linked to the given systemId may be communicated. + * + * If `false` the full extent of the protection data, in exactly the way it + * has been found in the content, should be communicated. + */ + canFilterProtectionData: boolean; + /** + * If `true`, the current device is known to not be able to begin playback of + * encrypted content if there's already clear content playing. + */ + failOnEncryptedAfterClear: boolean; /** * Enable/Disable fastSwitching: allow to replace lower-quality segments by * higher-quality ones to have a faster transition. @@ -513,7 +526,14 @@ function loadOrReloadPreparedContent( segmentSinksStore, segmentFetcherCreator, } = preparedContent; - const { drmSystemId, enableFastSwitching, initialTime, onCodecSwitch } = val; + const { + canFilterProtectionData, + failOnEncryptedAfterClear, + drmSystemId, + enableFastSwitching, + initialTime, + onCodecSwitch, + } = val; playbackObservationRef.onUpdate((observation) => { if (preparedContent.decipherabilityFreezeDetector.needToReload(observation)) { handleMediaSourceReload({ @@ -592,6 +612,8 @@ function loadOrReloadPreparedContent( maxVideoBufferSize, maxBufferAhead, maxBufferBehind, + canFilterProtectionData, + failOnEncryptedAfterClear, drmSystemId, enableFastSwitching, onCodecSwitch, @@ -874,6 +896,8 @@ function loadOrReloadPreparedContent( { initialTime: newInitialTime, drmSystemId: val.drmSystemId, + canFilterProtectionData: val.canFilterProtectionData, + failOnEncryptedAfterClear: val.failOnEncryptedAfterClear, enableFastSwitching: val.enableFastSwitching, onCodecSwitch: val.onCodecSwitch, }, diff --git a/src/core/stream/adaptation/adaptation_stream.ts b/src/core/stream/adaptation/adaptation_stream.ts index 6ba8b16daf..73e76d7a2c 100644 --- a/src/core/stream/adaptation/adaptation_stream.ts +++ b/src/core/stream/adaptation/adaptation_stream.ts @@ -441,6 +441,7 @@ export default function AdaptationStream( bufferGoal, maxBufferSize, drmSystemId: options.drmSystemId, + canFilterProtectionData: options.canFilterProtectionData, fastSwitchThreshold, }, }, diff --git a/src/core/stream/adaptation/types.ts b/src/core/stream/adaptation/types.ts index f1708127d7..9f15aab4ae 100644 --- a/src/core/stream/adaptation/types.ts +++ b/src/core/stream/adaptation/types.ts @@ -188,6 +188,14 @@ export interface IAdaptationStreamOptions { * those devices. */ enableFastSwitching: boolean; + /** + * If `true`, protection data as found in the content can be manipulated so + * e.g. only the data linked to the given systemId may be communicated. + * + * If `false` the full extent of the protection data, in exactly the way it + * has been found in the content, should be communicated. + */ + canFilterProtectionData: boolean; } /** Object indicating a choice of Adaptation made by the user. */ diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index c3c30cfcb8..e32c819e1d 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -109,6 +109,22 @@ export default function StreamOrchestrator( MAXIMUM_MAX_BUFFER_BEHIND, } = config.getCurrent(); + // Some DRM issues force us to check whether we're initially pushing clear + // segments. + // + // NOTE: Theoretically `initialPeriod` may not be the first Period for + // which we push segments (e.g. we may have a small seek which lead to + // another Period being streamed instead). + // This means that we could have an issue if `initialPeriod` leads to encrypted + // content, but the actually first-pushed segments do not. Here + // `shouldReloadOnEncryptedContent` would be set to `false` despiste the fact + // that it should be set to `true`. + // Yet, checking the first Period for which we pushed segments seems very hard, + // and all that for what is now (2024-07-31) a PlayReady bug, I don't have the + // will. + const shouldReloadOnEncryptedContent = + options.failOnEncryptedAfterClear && !hasEncryptedContentInPeriod(initialPeriod); + // Keep track of a unique BufferGarbageCollector created per // SegmentSink. const garbageCollectors = new WeakMapMemory((segmentSink: SegmentSink) => { @@ -449,6 +465,26 @@ export default function StreamOrchestrator( ): void { log.info("Stream: Creating new Stream for", bufferType, basePeriod.start); + if (shouldReloadOnEncryptedContent) { + if (hasEncryptedContentInPeriod(basePeriod)) { + playbackObserver.listen((pos) => { + if (pos.position.getWanted() > basePeriod.start - 0.01) { + callbacks.needsMediaSourceReload({ + timeOffset: 0, + minimumPosition: basePeriod.start, + maximumPosition: basePeriod.end, + }); + } else { + callbacks.lockedStream({ + period: basePeriod, + bufferType, + }); + } + }); + return; + } + } + /** * Contains properties linnked to the next chronological `PeriodStream` that * may be created here. @@ -656,6 +692,11 @@ export type IStreamOrchestratorOptions = IPeriodStreamOptions & { maxVideoBufferSize: IReadOnlySharedReference; maxBufferAhead: IReadOnlySharedReference; maxBufferBehind: IReadOnlySharedReference; + /** + * If `true`, the current device is known to not be able to begin playback of + * encrypted content if there's already clear content playing. + */ + failOnEncryptedAfterClear: boolean; }; /** Callbacks called by the `StreamOrchestrator` on various events. */ @@ -760,6 +801,24 @@ export interface ILockedStreamPayload { bufferType: IBufferType; } +/** + * Return `true` if the given Period has at least one Representation which is + * known to be encrypted. + * + * @param {Object} period + * @returns {boolean} + */ +function hasEncryptedContentInPeriod(period: IPeriod): boolean { + for (const adaptation of period.getAdaptations()) { + for (const representation of adaptation.representations) { + if (representation.contentProtections !== undefined) { + return true; + } + } + } + return false; +} + /** * Returns `true` if low-level buffers have to be "flushed" after the given * `cleanedRanges` time ranges have been removed from an audio or video diff --git a/src/core/stream/representation/representation_stream.ts b/src/core/stream/representation/representation_stream.ts index 6bf6b57d90..c507c336cb 100644 --- a/src/core/stream/representation/representation_stream.ts +++ b/src/core/stream/representation/representation_stream.ts @@ -94,7 +94,13 @@ export default function RepresentationStream( parentCancelSignal: CancellationSignal, ): void { const { period, adaptation, representation } = content; - const { bufferGoal, maxBufferSize, drmSystemId, fastSwitchThreshold } = options; + const { + bufferGoal, + maxBufferSize, + drmSystemId, + canFilterProtectionData, + fastSwitchThreshold, + } = options; const bufferType = adaptation.type; /** `TaskCanceller` stopping ALL operations performed by the `RepresentationStream` */ @@ -148,7 +154,7 @@ export default function RepresentationStream( // If the DRM system id is already known, and if we already have encryption data // for it, we may not need to wait until the initialization segment is loaded to // signal required protection data, thus performing License negotiations sooner - if (drmSystemId !== undefined) { + if (canFilterProtectionData && drmSystemId !== undefined) { const encryptionData = representation.getEncryptionData(drmSystemId); // If some key ids are not known yet, it may be safer to wait for this initialization diff --git a/src/core/stream/representation/types.ts b/src/core/stream/representation/types.ts index da5d4389b8..a3274afebb 100644 --- a/src/core/stream/representation/types.ts +++ b/src/core/stream/representation/types.ts @@ -323,6 +323,14 @@ export interface IRepresentationStreamOptions { * `0` can be emitted to disable any kind of fast-switching. */ fastSwitchThreshold: IReadOnlySharedReference; + /** + * If `true`, protection data as found in the content can be manipulated so + * e.g. only the data linked to the given systemId may be communicated. + * + * If `false` the full extent of the protection data, in exactly the way it + * has been found in the content, should be communicated. + */ + canFilterProtectionData: boolean; } /** Object indicating a choice of allowed Representations made by the user. */ diff --git a/src/main_thread/decrypt/__tests__/__global__/get_license.test.ts b/src/main_thread/decrypt/__tests__/__global__/get_license.test.ts index 8f5a8a3003..6a8020c583 100644 --- a/src/main_thread/decrypt/__tests__/__global__/get_license.test.ts +++ b/src/main_thread/decrypt/__tests__/__global__/get_license.test.ts @@ -10,6 +10,7 @@ import { describe, afterEach, it, expect, vi } from "vitest"; import type { IPlayerError } from "../../../../public_types"; import { concat } from "../../../../utils/byte_parsing"; +import type { IContentDecryptorStateData } from "../../types"; import { formatFakeChallengeFromInitData, MediaKeySessionImpl, @@ -376,37 +377,44 @@ async function checkGetLicense({ // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); - contentDecryptor.addEventListener("stateChange", (newState: number) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { - rej(new Error(`Unexpected state: ${newState}`)); + const waitForAttachmentCallback = (newState: IContentDecryptorStateData) => { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { + rej(new Error(`Unexpected state: ${newState.name}`)); } - contentDecryptor.removeEventListener("stateChange"); + contentDecryptor.removeEventListener("stateChange", waitForAttachmentCallback); contentDecryptor.attach(); - }); - - contentDecryptor.addEventListener("error", (error: unknown) => { - if (shouldFail) { - try { - checkKeyLoadError(error); - expect(mockGetLicense).toHaveBeenCalledTimes(maxRetries + 1); - for (let i = 1; i <= maxRetries + 1; i++) { - // TODO there's seem to be an issue with how vitest check Uint8Array - // equality - expect(mockGetLicense).toHaveBeenNthCalledWith( - i, - challenge, - "license-request", - ); + }; + contentDecryptor.addEventListener("stateChange", waitForAttachmentCallback); + contentDecryptor.addEventListener( + "stateChange", + (state: IContentDecryptorStateData) => { + if (state.name !== ContentDecryptorState.Error) { + return; + } + const error = state.payload; + if (shouldFail) { + try { + checkKeyLoadError(error); + expect(mockGetLicense).toHaveBeenCalledTimes(maxRetries + 1); + for (let i = 1; i <= maxRetries + 1; i++) { + // TODO there's seem to be an issue with how vitest check Uint8Array + // equality + expect(mockGetLicense).toHaveBeenNthCalledWith( + i, + challenge, + "license-request", + ); + } + expect(mockUpdate).toHaveBeenCalledTimes(0); + res(); + } catch (e) { + rej(e); } - expect(mockUpdate).toHaveBeenCalledTimes(0); - res(); - } catch (e) { - rej(e); + } else { + rej(new Error(`Unexpected error: ${error}`)); } - } else { - rej(new Error(`Unexpected error: ${error}`)); - } - }); + }, + ); contentDecryptor.addEventListener("warning", (warning: Error) => { if (warningsLeft-- > 0) { diff --git a/src/main_thread/decrypt/__tests__/__global__/init_data.test.ts b/src/main_thread/decrypt/__tests__/__global__/init_data.test.ts index e8bc4bf327..dabba3c607 100644 --- a/src/main_thread/decrypt/__tests__/__global__/init_data.test.ts +++ b/src/main_thread/decrypt/__tests__/__global__/init_data.test.ts @@ -52,7 +52,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -104,7 +104,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -176,7 +176,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -276,7 +276,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -347,7 +347,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -403,7 +403,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -481,7 +481,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -577,7 +577,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); @@ -661,7 +661,7 @@ describe("decrypt - global tests - init data", () => { // == test == const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState !== ContentDecryptorState.WaitingForAttachment) { + if (newState.name !== ContentDecryptorState.WaitingForAttachment) { rej(new Error(`Unexpected state: ${newState}`)); } contentDecryptor.removeEventListener("stateChange"); diff --git a/src/main_thread/decrypt/__tests__/__global__/media_key_system_access.test.ts b/src/main_thread/decrypt/__tests__/__global__/media_key_system_access.test.ts index a4c3834607..51c511b170 100644 --- a/src/main_thread/decrypt/__tests__/__global__/media_key_system_access.test.ts +++ b/src/main_thread/decrypt/__tests__/__global__/media_key_system_access.test.ts @@ -9,6 +9,7 @@ import { describe, beforeEach, afterEach, it, expect, vi } from "vitest"; /* eslint-disable no-restricted-properties */ import type { ICustomMediaKeySystemAccess } from "../../../../compat/eme"; +import { ContentDecryptorState } from "../../types"; import { defaultKSConfig, defaultPRRecommendationKSConfig, @@ -708,8 +709,10 @@ describe("decrypt - global tests - media key system access", () => { const mediaElement = document.createElement("video"); const contentDecryptor = new ContentDecryptor(mediaElement, config); - contentDecryptor.addEventListener("error", (error: any) => { - rej(error); + contentDecryptor.addEventListener("stateChange", (state: any) => { + if (state.name === ContentDecryptorState.Error) { + rej(state.payload); + } }); setTimeout(() => { expect(mockRequestMediaKeySystemAccess).toHaveBeenCalledTimes(1); @@ -745,8 +748,10 @@ describe("decrypt - global tests - media key system access", () => { const mediaElement = document.createElement("video"); const contentDecryptor = new ContentDecryptor(mediaElement, config); - contentDecryptor.addEventListener("error", (error: any) => { - rej(error); + contentDecryptor.addEventListener("stateChange", (state: any) => { + if (state.name === ContentDecryptorState.Error) { + rej(state.payload); + } }); setTimeout(() => { expect(mockRequestMediaKeySystemAccess).toHaveBeenCalledTimes(2); @@ -789,8 +794,10 @@ describe("decrypt - global tests - media key system access", () => { { type: "baz", getLicense: neverCalledFn }, ]; contentDecryptor = new ContentDecryptor(mediaElement, config); - contentDecryptor.addEventListener("error", (error: any) => { - rej(error); + contentDecryptor.addEventListener("stateChange", (state: any) => { + if (state.name === ContentDecryptorState.Error) { + rej(state.payload); + } }); setTimeout(() => { expect(rmksHasBeenCalled).toEqual(true); @@ -821,9 +828,11 @@ describe("decrypt - global tests - media key system access", () => { const config = [{ type: "foo", getLicense: neverCalledFn }]; const contentDecryptor = new ContentDecryptor(mediaElement, config); - contentDecryptor.addEventListener("error", () => { - expect(rmksHasBeenCalled).toEqual(true); - res(); + contentDecryptor.addEventListener("stateChange", (state: any) => { + if (state.name === ContentDecryptorState.Error) { + expect(rmksHasBeenCalled).toEqual(true); + res(); + } }); setTimeout(() => { rej(new Error("timeout exceeded")); diff --git a/src/main_thread/decrypt/__tests__/__global__/media_keys.test.ts b/src/main_thread/decrypt/__tests__/__global__/media_keys.test.ts index d17b5c3c42..c55c7de646 100644 --- a/src/main_thread/decrypt/__tests__/__global__/media_keys.test.ts +++ b/src/main_thread/decrypt/__tests__/__global__/media_keys.test.ts @@ -115,7 +115,7 @@ describe("decrypt - global tests - media key system access", () => { contentDecryptor.addEventListener("stateChange", (newState: any) => { receivedStateChange++; try { - expect(newState).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(newState.name).toEqual(ContentDecryptorState.WaitingForAttachment); expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); } catch (err) { rej(err); @@ -123,9 +123,10 @@ describe("decrypt - global tests - media key system access", () => { setTimeout(() => { try { expect(receivedStateChange).toEqual(1); - expect(contentDecryptor.getState()).toEqual( - ContentDecryptorState.WaitingForAttachment, - ); + expect(contentDecryptor.getState()).toEqual({ + name: ContentDecryptorState.WaitingForAttachment, + payload: null, + }); contentDecryptor.dispose(); } catch (err) { rej(err); @@ -148,17 +149,19 @@ describe("decrypt - global tests - media key system access", () => { return new Promise((res, rej) => { const contentDecryptor1 = new ContentDecryptor(videoElt, ksConfig); let receivedStateChange1 = 0; - contentDecryptor1.addEventListener("error", rej); contentDecryptor1.addEventListener("stateChange", (state1: any) => { receivedStateChange1++; + if (state1.name === ContentDecryptorState.Error) { + rej(state1.payload); + } try { if (receivedStateChange1 === 2) { - expect(state1).toEqual(ContentDecryptorState.ReadyForContent); + expect(state1.name).toEqual(ContentDecryptorState.ReadyForContent); return; } else if (receivedStateChange1 !== 1) { throw new Error("Unexpected stateChange event."); } - expect(state1).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(state1.name).toEqual(ContentDecryptorState.WaitingForAttachment); expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); contentDecryptor1.attach(); } catch (err) { @@ -169,17 +172,19 @@ describe("decrypt - global tests - media key system access", () => { contentDecryptor1.dispose(); const contentDecryptor2 = new ContentDecryptor(videoElt, ksConfig); let receivedStateChange2 = 0; - contentDecryptor2.addEventListener("error", rej); contentDecryptor2.addEventListener("stateChange", (state2: any) => { receivedStateChange2++; + if (state2.name === ContentDecryptorState.Error) { + rej(state2.payload); + } try { if (receivedStateChange2 === 2) { - expect(state2).toEqual(ContentDecryptorState.ReadyForContent); + expect(state2.name).toEqual(ContentDecryptorState.ReadyForContent); return; } else if (receivedStateChange2 !== 1) { throw new Error("Unexpected stateChange event."); } - expect(state2).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(state2.name).toEqual(ContentDecryptorState.WaitingForAttachment); expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); contentDecryptor2.attach(); setTimeout(() => { @@ -214,17 +219,19 @@ describe("decrypt - global tests - media key system access", () => { return new Promise((res, rej) => { const contentDecryptor1 = new ContentDecryptor(videoElt, ksConfig); let receivedStateChange1 = 0; - contentDecryptor1.addEventListener("error", rej); contentDecryptor1.addEventListener("stateChange", (state1: any) => { receivedStateChange1++; + if (state1.name === ContentDecryptorState.Error) { + rej(state1.payload); + } try { if (receivedStateChange1 === 2) { - expect(state1).toEqual(ContentDecryptorState.ReadyForContent); + expect(state1.name).toEqual(ContentDecryptorState.ReadyForContent); return; } else if (receivedStateChange1 !== 1) { throw new Error("Unexpected stateChange event."); } - expect(state1).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(state1.name).toEqual(ContentDecryptorState.WaitingForAttachment); expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); contentDecryptor1.attach(); } catch (err) { @@ -235,17 +242,19 @@ describe("decrypt - global tests - media key system access", () => { contentDecryptor1.dispose(); const contentDecryptor2 = new ContentDecryptor(videoElt, ksConfig); let receivedStateChange2 = 0; - contentDecryptor2.addEventListener("error", rej); contentDecryptor2.addEventListener("stateChange", (state2: any) => { receivedStateChange2++; + if (state2.name === ContentDecryptorState.Error) { + rej(state2.payload); + } try { if (receivedStateChange2 === 2) { - expect(state2).toEqual(ContentDecryptorState.ReadyForContent); + expect(state2.name).toEqual(ContentDecryptorState.ReadyForContent); return; } else if (receivedStateChange2 !== 1) { throw new Error("Unexpected stateChange event."); } - expect(state2).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(state2.name).toEqual(ContentDecryptorState.WaitingForAttachment); expect(mockCreateMediaKeys).toHaveBeenCalledTimes(2); contentDecryptor2.attach(); setTimeout(() => { @@ -281,17 +290,19 @@ describe("decrypt - global tests - media key system access", () => { return new Promise((res, rej) => { const contentDecryptor1 = new ContentDecryptor(videoElt, ksConfig); let receivedStateChange1 = 0; - contentDecryptor1.addEventListener("error", rej); contentDecryptor1.addEventListener("stateChange", (state1: any) => { receivedStateChange1++; + if (state1.name === ContentDecryptorState.Error) { + rej(state1.payload); + } try { if (receivedStateChange1 === 2) { - expect(state1).toEqual(ContentDecryptorState.ReadyForContent); + expect(state1.name).toEqual(ContentDecryptorState.ReadyForContent); return; } else if (receivedStateChange1 !== 1) { throw new Error("Unexpected stateChange event."); } - expect(state1).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(state1.name).toEqual(ContentDecryptorState.WaitingForAttachment); expect(mockCreateMediaKeys).toHaveBeenCalledTimes(1); contentDecryptor1.attach(); } catch (err) { @@ -302,17 +313,19 @@ describe("decrypt - global tests - media key system access", () => { contentDecryptor1.dispose(); const contentDecryptor2 = new ContentDecryptor(videoElt, ksConfig); let receivedStateChange2 = 0; - contentDecryptor2.addEventListener("error", rej); contentDecryptor2.addEventListener("stateChange", (state2: any) => { receivedStateChange2++; + if (state2.name === ContentDecryptorState.Error) { + rej(state2.payload); + } try { if (receivedStateChange2 === 2) { - expect(state2).toEqual(ContentDecryptorState.ReadyForContent); + expect(state2.name).toEqual(ContentDecryptorState.ReadyForContent); return; } else if (receivedStateChange2 !== 1) { throw new Error("Unexpected stateChange event."); } - expect(state2).toEqual(ContentDecryptorState.WaitingForAttachment); + expect(state2.name).toEqual(ContentDecryptorState.WaitingForAttachment); expect(mockCreateMediaKeys).toHaveBeenCalledTimes(2); contentDecryptor2.attach(); setTimeout(() => { @@ -346,7 +359,7 @@ describe("decrypt - global tests - media key system access", () => { const contentDecryptor = new ContentDecryptor(videoElt, ksConfig); return new Promise((res) => { contentDecryptor.addEventListener("stateChange", (newState: any) => { - if (newState === ContentDecryptorState.WaitingForAttachment) { + if (newState.name === ContentDecryptorState.WaitingForAttachment) { contentDecryptor.removeEventListener("stateChange"); contentDecryptor.attach(); setTimeout(() => { diff --git a/src/main_thread/decrypt/__tests__/__global__/server_certificate.test.ts b/src/main_thread/decrypt/__tests__/__global__/server_certificate.test.ts index 42fd9f01ab..424e9aac83 100644 --- a/src/main_thread/decrypt/__tests__/__global__/server_certificate.test.ts +++ b/src/main_thread/decrypt/__tests__/__global__/server_certificate.test.ts @@ -59,7 +59,7 @@ describe("decrypt - global tests - server certificate", () => { return new Promise((res) => { contentDecryptor.addEventListener("stateChange", (state: any) => { - if (state === ContentDecryptorState.WaitingForAttachment) { + if (state.name === ContentDecryptorState.WaitingForAttachment) { contentDecryptor.removeEventListener("stateChange"); setTimeout(() => { expect(mockSetMediaKeys).not.toHaveBeenCalled(); @@ -101,7 +101,7 @@ describe("decrypt - global tests - server certificate", () => { const contentDecryptor = new ContentDecryptor(videoElt, ksConfigCert); contentDecryptor.addEventListener("stateChange", (state: any) => { - if (state === ContentDecryptorState.WaitingForAttachment) { + if (state.name === ContentDecryptorState.WaitingForAttachment) { contentDecryptor.removeEventListener("stateChange"); setTimeout(() => { expect(mockSetMediaKeys).not.toHaveBeenCalled(); @@ -142,7 +142,7 @@ describe("decrypt - global tests - server certificate", () => { const contentDecryptor = new ContentDecryptor(videoElt, ksConfigCert); contentDecryptor.addEventListener("stateChange", (state: any) => { - if (state === ContentDecryptorState.WaitingForAttachment) { + if (state.name === ContentDecryptorState.WaitingForAttachment) { contentDecryptor.removeEventListener("stateChange"); contentDecryptor.attach(); } @@ -181,7 +181,7 @@ describe("decrypt - global tests - server certificate", () => { const contentDecryptor = new ContentDecryptor(videoElt, ksConfigCert); contentDecryptor.addEventListener("stateChange", (state: any) => { - if (state === ContentDecryptorState.WaitingForAttachment) { + if (state.name === ContentDecryptorState.WaitingForAttachment) { contentDecryptor.removeEventListener("stateChange"); contentDecryptor.attach(); } @@ -231,7 +231,7 @@ describe("decrypt - global tests - server certificate", () => { const contentDecryptor = new ContentDecryptor(videoElt, ksConfigCert); contentDecryptor.addEventListener("stateChange", (state: any) => { - if (state === ContentDecryptorState.WaitingForAttachment) { + if (state.name === ContentDecryptorState.WaitingForAttachment) { contentDecryptor.removeEventListener("stateChange"); setTimeout(() => { expect(mockSetMediaKeys).not.toHaveBeenCalled(); diff --git a/src/main_thread/decrypt/__tests__/__global__/utils.ts b/src/main_thread/decrypt/__tests__/__global__/utils.ts index 3eea8f2667..8977ff1a96 100644 --- a/src/main_thread/decrypt/__tests__/__global__/utils.ts +++ b/src/main_thread/decrypt/__tests__/__global__/utils.ts @@ -19,6 +19,7 @@ import EventEmitter from "../../../../utils/event_emitter"; import flatMap from "../../../../utils/flat_map"; import { strToUtf8, utf8ToStr } from "../../../../utils/string_parsing"; import type { CancellationSignal } from "../../../../utils/task_canceller"; +import { ContentDecryptorState } from "../../types"; /** Default MediaKeySystemAccess configuration used by the RxPlayer. */ export const defaultKSConfig = [ @@ -485,8 +486,10 @@ export function testContentDecryptorError( ): Promise { return new Promise((res, rej) => { const contentDecryptor = new ContentDecryptor(mediaElement, keySystemsConfigs); - contentDecryptor.addEventListener("error", (error: any) => { - res(error); + contentDecryptor.addEventListener("stateChange", (state: any) => { + if (state.name === ContentDecryptorState.Error) { + res(state.payload); + } }); setTimeout(() => { rej(new Error("Timeout exceeded")); diff --git a/src/main_thread/decrypt/content_decryptor.ts b/src/main_thread/decrypt/content_decryptor.ts index 8da27ae06d..c0caf5901c 100644 --- a/src/main_thread/decrypt/content_decryptor.ts +++ b/src/main_thread/decrypt/content_decryptor.ts @@ -45,6 +45,12 @@ import type { IMediaKeySessionStores, IProcessedProtectionData, IContentDecryptorEvent, + IContentDecryptorStateData, + IInitializingContentDecryptorState, + IWaitingForAttachmentContentDecryptorState, + IReadyForContentContentDecryptorState, + IDisposedContentDecryptorState, + IErrorContentDecryptorState, } from "./types"; import { MediaKeySessionLoadingType, ContentDecryptorState } from "./types"; import { DecommissionedSessionError } from "./utils/check_key_statuses"; @@ -86,12 +92,6 @@ export default class ContentDecryptor extends EventEmitter { - const { options, mediaKeySystemAccess } = mediaKeysInfo; + const { mediaKeySystemAccess } = mediaKeysInfo; this._supportedCodecWhenEncrypted = mediaKeysInfo.codecSupport; /** * String identifying the key system, allowing the rest of the code to * only advertise the required initialization data for license requests. - * - * Note that we only set this value if retro-compatibility to older - * persistent logic in the RxPlayer is not important, as the - * optimizations this property unlocks can break the loading of - * MediaKeySessions persisted in older RxPlayer's versions. */ - let systemId: string | undefined; - if ( - isNullOrUndefined(options.persistentLicenseConfig) || - options.persistentLicenseConfig.disableRetroCompatibility === true - ) { - systemId = getDrmSystemId(mediaKeySystemAccess.keySystem); - } - + const systemId = getDrmSystemId(mediaKeySystemAccess.keySystem); this.systemId = systemId; - if (this._stateData.state === ContentDecryptorState.Initializing) { + if (this._stateData.state.name === ContentDecryptorState.Initializing) { this._stateData = { - state: ContentDecryptorState.WaitingForAttachment, + state: { + name: ContentDecryptorState.WaitingForAttachment, + payload: null, + }, isInitDataQueueLocked: true, isMediaKeysAttached: MediaKeyAttachmentStatus.NotAttached, data: { mediaKeysInfo, mediaElement }, @@ -229,7 +222,7 @@ export default class ContentDecryptor extends EventEmitter, period: IPeriodMetadata) { } /** Possible states the ContentDecryptor is in and associated data for each one. */ -type IContentDecryptorStateData = +type IContentDecryptorStateMetadata = | IInitializingStateData | IWaitingForAttachmentStateData | IReadyForContentStateDataUnattached @@ -1162,15 +1174,15 @@ type IContentDecryptorStateData = | IDisposeStateData | IErrorStateData; -/** Skeleton that all variants of `IContentDecryptorStateData` use. */ +/** Skeleton that all variants of `IContentDecryptorStateMetadata` use. */ interface IContentDecryptorStateBase< - TStateName extends ContentDecryptorState, + TStateData extends IContentDecryptorStateData, TIsQueueLocked extends boolean | undefined, TIsMediaKeyAttached extends MediaKeyAttachmentStatus | undefined, TData, > { /** Identify the ContentDecryptor's state. */ - state: TStateName; + state: TStateData; /** * If `true`, the `ContentDecryptor` will wait before processing * newly-received initialization data. @@ -1198,7 +1210,7 @@ const enum MediaKeyAttachmentStatus { /** ContentDecryptor's internal data when in the `Initializing` state. */ type IInitializingStateData = IContentDecryptorStateBase< - ContentDecryptorState.Initializing, + IInitializingContentDecryptorState, true, // isInitDataQueueLocked MediaKeyAttachmentStatus.NotAttached, // isMediaKeysAttached null // data @@ -1206,7 +1218,7 @@ type IInitializingStateData = IContentDecryptorStateBase< /** ContentDecryptor's internal data when in the `WaitingForAttachment` state. */ type IWaitingForAttachmentStateData = IContentDecryptorStateBase< - ContentDecryptorState.WaitingForAttachment, + IWaitingForAttachmentContentDecryptorState, true, // isInitDataQueueLocked MediaKeyAttachmentStatus.NotAttached, // isMediaKeysAttached // data @@ -1218,7 +1230,7 @@ type IWaitingForAttachmentStateData = IContentDecryptorStateBase< * it has attached the `MediaKeys` to the media element. */ type IReadyForContentStateDataUnattached = IContentDecryptorStateBase< - ContentDecryptorState.ReadyForContent, + IReadyForContentContentDecryptorState, true, // isInitDataQueueLocked MediaKeyAttachmentStatus.NotAttached | MediaKeyAttachmentStatus.Pending, // isMediaKeysAttached { mediaKeysInfo: IMediaKeysInfos; mediaElement: IMediaElement } // data @@ -1229,7 +1241,7 @@ type IReadyForContentStateDataUnattached = IContentDecryptorStateBase< * it has attached the `MediaKeys` to the media element. */ type IReadyForContentStateDataAttached = IContentDecryptorStateBase< - ContentDecryptorState.ReadyForContent, + IReadyForContentContentDecryptorState, boolean, // isInitDataQueueLocked MediaKeyAttachmentStatus.Attached, // isMediaKeysAttached { @@ -1246,7 +1258,7 @@ type IReadyForContentStateDataAttached = IContentDecryptorStateBase< /** ContentDecryptor's internal data when in the `Disposed` state. */ type IDisposeStateData = IContentDecryptorStateBase< - ContentDecryptorState.Disposed, + IDisposedContentDecryptorState, undefined, // isInitDataQueueLocked undefined, // isMediaKeysAttached null // data @@ -1254,7 +1266,7 @@ type IDisposeStateData = IContentDecryptorStateBase< /** ContentDecryptor's internal data when in the `Error` state. */ type IErrorStateData = IContentDecryptorStateBase< - ContentDecryptorState.Error, + IErrorContentDecryptorState, undefined, // isInitDataQueueLocked undefined, // isMediaKeysAttached null // data diff --git a/src/main_thread/decrypt/types.ts b/src/main_thread/decrypt/types.ts index 04e42868a4..fe2888b1b4 100644 --- a/src/main_thread/decrypt/types.ts +++ b/src/main_thread/decrypt/types.ts @@ -27,14 +27,6 @@ import type PersistentSessionsStore from "./utils/persistent_sessions_store"; /** Events sent by the `ContentDecryptor`, in a `{ event: payload }` format. */ export interface IContentDecryptorEvent { - /** - * Event emitted when a major error occured which made the ContentDecryptor - * stopped. - * When that event is sent, the `ContentDecryptor` is in the `Error` state and - * cannot be used anymore. - */ - error: Error; - /** * Event emitted when a minor error occured which the ContentDecryptor can * recover from. @@ -46,7 +38,7 @@ export interface IContentDecryptorEvent { * States are a central aspect of the `ContentDecryptor`, be sure to check the * ContentDecryptorState type. */ - stateChange: ContentDecryptorState; + stateChange: IContentDecryptorStateData; blackListProtectionData: IProcessedProtectionData; @@ -57,6 +49,64 @@ export interface IContentDecryptorEvent { }; } +/** Describes the current "state" the `ContentDecryptor` is in. */ +export type IContentDecryptorStateData = + | IInitializingContentDecryptorState + | IWaitingForAttachmentContentDecryptorState + | IReadyForContentContentDecryptorState + | IErrorContentDecryptorState + | IDisposedContentDecryptorState; + +/** Object sent when the `ContentDecryptorState` switches to `Initializing`. */ +export interface IInitializingContentDecryptorState { + name: ContentDecryptorState.Initializing; + payload: null; +} + +/** Object sent when the `ContentDecryptorState` switches to `WaitingForAttachment`. */ +export interface IWaitingForAttachmentContentDecryptorState { + name: ContentDecryptorState.WaitingForAttachment; + payload: null; +} + +/** Object sent when the `ContentDecryptorState` switches to `ReadyForContent`. */ +export interface IReadyForContentContentDecryptorState { + name: ContentDecryptorState.ReadyForContent; + payload: { + /** + * Hexa characters identifying the "system id" of the content protection + * technology. + * `undefined` if unknown. + */ + systemId: string | undefined; + /** + * If `true`, protection data as found in the content can be manipulated so + * e.g. only the data linked to the given systemId may be communicated. + * + * If `false` the full extent of the protection data, in exactly the way it + * has been found in the content, should be communicated. + */ + canFilterProtectionData: boolean; + /** + * If `true`, the current device is known to not be able to begin playback of + * encrypted content if there's already clear content playing. + */ + failOnEncryptedAfterClear: boolean; + }; +} + +/** Object sent when the `ContentDecryptorState` switches to `Error`. */ +export interface IErrorContentDecryptorState { + name: ContentDecryptorState.Error; + payload: Error; +} + +/** Object sent when the `ContentDecryptorState` switches to `Disposed`. */ +export interface IDisposedContentDecryptorState { + name: ContentDecryptorState.Disposed; + payload: null; +} + /** Enumeration of the various "state" the `ContentDecryptor` can be in. */ export enum ContentDecryptorState { /** @@ -65,7 +115,6 @@ export enum ContentDecryptorState { * This is is the initial state of the ContentDecryptor. */ Initializing, - /** * The `ContentDecryptor` has been initialized. * You should now called the `attach` method when you want to add decryption diff --git a/src/main_thread/init/directfile_content_initializer.ts b/src/main_thread/init/directfile_content_initializer.ts index 94501cd309..2b82017140 100644 --- a/src/main_thread/init/directfile_content_initializer.ts +++ b/src/main_thread/init/directfile_content_initializer.ts @@ -137,7 +137,7 @@ export default class DirectFileContentInitializer extends ContentInitializer { drmInitRef.onUpdate( (evt, stopListeningToDrmUpdates) => { - if (evt.initializationState.type === "uninitialized") { + if (evt.type === "uninitialized") { return; // nothing done yet } stopListeningToDrmUpdates(); @@ -150,11 +150,11 @@ export default class DirectFileContentInitializer extends ContentInitializer { clearElementSrc(mediaElement); }); - if (evt.initializationState.type === "awaiting-media-link") { - evt.initializationState.value.isMediaLinked.setValue(true); + if (evt.type === "awaiting-media-link") { + evt.value.isMediaLinked.setValue(true); drmInitRef.onUpdate( (newDrmStatus, stopListeningToDrmUpdatesAgain) => { - if (newDrmStatus.initializationState.type === "initialized") { + if (newDrmStatus.type === "initialized") { stopListeningToDrmUpdatesAgain(); this._seekAndPlay(mediaElement, playbackObserver); } @@ -162,7 +162,7 @@ export default class DirectFileContentInitializer extends ContentInitializer { { emitCurrentValue: true, clearSignal: cancelSignal }, ); } else { - assert(evt.initializationState.type === "initialized"); + assert(evt.type === "initialized"); this._seekAndPlay(mediaElement, playbackObserver); } }, diff --git a/src/main_thread/init/media_source_content_initializer.ts b/src/main_thread/init/media_source_content_initializer.ts index 2b9df958dc..e9d64e1378 100644 --- a/src/main_thread/init/media_source_content_initializer.ts +++ b/src/main_thread/init/media_source_content_initializer.ts @@ -210,7 +210,11 @@ export default class MediaSourceContentInitializer extends ContentInitializer { mediaElement, initResult.mediaSource, playbackObserver, - initResult.drmSystemId, + { + drmSystemId: initResult.drmSystemId, + canFilterProtectionData: initResult.canFilterProtectionData, + failOnEncryptedAfterClear: initResult.failOnEncryptedAfterClear, + }, initResult.unlinkMediaSource, ), ) @@ -245,6 +249,8 @@ export default class MediaSourceContentInitializer extends ContentInitializer { private _initializeMediaSourceAndDecryption(mediaElement: IMediaElement): Promise<{ mediaSource: MainMediaSourceInterface; drmSystemId: string | undefined; + canFilterProtectionData: boolean; + failOnEncryptedAfterClear: boolean; unlinkMediaSource: TaskCanceller; }> { const initCanceller = this._initCanceller; @@ -319,7 +325,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { drmInitRef.onUpdate( (drmStatus, stopListeningToDrmUpdates) => { - if (drmStatus.initializationState.type === "uninitialized") { + if (drmStatus.type === "uninitialized") { return; } stopListeningToDrmUpdates(); @@ -329,15 +335,19 @@ export default class MediaSourceContentInitializer extends ContentInitializer { createMediaSource(mediaElement, mediaSourceCanceller.signal) .then((mediaSource) => { const lastDrmStatus = drmInitRef.getValue(); - if (lastDrmStatus.initializationState.type === "awaiting-media-link") { - lastDrmStatus.initializationState.value.isMediaLinked.setValue(true); + if (lastDrmStatus.type === "awaiting-media-link") { + lastDrmStatus.value.isMediaLinked.setValue(true); drmInitRef.onUpdate( (newDrmStatus, stopListeningToDrmUpdatesAgain) => { - if (newDrmStatus.initializationState.type === "initialized") { + if (newDrmStatus.type === "initialized") { stopListeningToDrmUpdatesAgain(); resolve({ mediaSource, - drmSystemId: newDrmStatus.drmSystemId, + drmSystemId: newDrmStatus.value.drmSystemId, + canFilterProtectionData: + newDrmStatus.value.canFilterProtectionData, + failOnEncryptedAfterClear: + newDrmStatus.value.failOnEncryptedAfterClear, unlinkMediaSource: mediaSourceCanceller, }); return; @@ -345,10 +355,12 @@ export default class MediaSourceContentInitializer extends ContentInitializer { }, { emitCurrentValue: true, clearSignal: initCanceller.signal }, ); - } else if (drmStatus.initializationState.type === "initialized") { + } else if (drmStatus.type === "initialized") { resolve({ mediaSource, - drmSystemId: drmStatus.drmSystemId, + drmSystemId: drmStatus.value.drmSystemId, + canFilterProtectionData: drmStatus.value.canFilterProtectionData, + failOnEncryptedAfterClear: drmStatus.value.failOnEncryptedAfterClear, unlinkMediaSource: mediaSourceCanceller, }); return; @@ -370,7 +382,11 @@ export default class MediaSourceContentInitializer extends ContentInitializer { mediaElement: IMediaElement, initialMediaSource: MainMediaSourceInterface, playbackObserver: IMediaElementPlaybackObserver, - drmSystemId: string | undefined, + decryptionParameters: { + drmSystemId: string | undefined; + canFilterProtectionData: boolean; + failOnEncryptedAfterClear: boolean; + }, initialMediaSourceCanceller: TaskCanceller, ): Promise { const { @@ -425,7 +441,12 @@ export default class MediaSourceContentInitializer extends ContentInitializer { /** Choose the right "Representation" for a given "Adaptation". */ const representationEstimator = AdaptiveRepresentationSelector(adaptiveOptions); const subBufferOptions = objectAssign( - { textTrackOptions, drmSystemId }, + { + textTrackOptions, + drmSystemId: decryptionParameters.drmSystemId, + canFilterProtectionData: decryptionParameters.canFilterProtectionData, + failOnEncryptedAfterClear: decryptionParameters.failOnEncryptedAfterClear, + }, bufferOptions, ); @@ -1083,7 +1104,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { supportedIfEncrypted = true; } else { const contentDecryptor = this._decryptionCapabilities.value; - if (contentDecryptor.getState() !== ContentDecryptorState.Initializing) { + if (contentDecryptor.getState().name !== ContentDecryptorState.Initializing) { // No information is available regarding the support status. // Defaulting to assume the codec is supported. supportedIfEncrypted = diff --git a/src/main_thread/init/multi_thread_content_initializer.ts b/src/main_thread/init/multi_thread_content_initializer.ts index b45fa44faa..86d6ff71b3 100644 --- a/src/main_thread/init/multi_thread_content_initializer.ts +++ b/src/main_thread/init/multi_thread_content_initializer.ts @@ -51,7 +51,7 @@ import SharedReference from "../../utils/reference"; import { RequestError } from "../../utils/request"; import type { CancellationSignal } from "../../utils/task_canceller"; import TaskCanceller, { CancellationError } from "../../utils/task_canceller"; -import type { IContentProtection } from "../decrypt"; +import type { IContentDecryptorStateData, IContentProtection } from "../decrypt"; import type IContentDecryptor from "../decrypt"; import { ContentDecryptorState, getKeySystemConfiguration } from "../decrypt"; import type { ITextDisplayer } from "../text_displayer"; @@ -308,7 +308,7 @@ export default class MultiThreadContentInitializer extends ContentInitializer { ); drmInitializationStatus.onUpdate( (initializationStatus, stopListeningDrm) => { - if (initializationStatus.initializationState.type === "initialized") { + if (initializationStatus.type === "initialized") { stopListeningDrm(); this._startPlaybackIfReady(playbackStartParams); } @@ -1130,7 +1130,7 @@ export default class MultiThreadContentInitializer extends ContentInitializer { reloadMediaSource: () => void, cancelSignal: CancellationSignal, ): { - statusRef: IReadOnlySharedReference; + statusRef: IReadOnlySharedReference; contentDecryptor: IContentDecryptor | null; } { const { keySystems } = this._settings; @@ -1151,12 +1151,12 @@ export default class MultiThreadContentInitializer extends ContentInitializer { { clearSignal: cancelSignal }, ); const ref = new SharedReference({ - initializationState: { - type: "initialized" as const, - value: null, + type: "initialized" as const, + value: { + drmSystemId: undefined, + canFilterProtectionData: true, + failOnEncryptedAfterClear: false, }, - contentDecryptor: null, - drmSystemId: undefined, }); ref.finish(); // We know that no new value will be triggered return { statusRef: ref, contentDecryptor: null }; @@ -1174,16 +1174,13 @@ export default class MultiThreadContentInitializer extends ContentInitializer { } log.debug("MTCI: Creating ContentDecryptor"); const contentDecryptor = new ContentDecryptor(mediaElement, keySystems); - const drmStatusRef = new SharedReference( - { - initializationState: { type: "uninitialized", value: null }, - drmSystemId: undefined, - }, + const drmStatusRef = new SharedReference( + { type: "uninitialized", value: null }, cancelSignal, ); - const updateCodecSupportOnStateChange = (state: ContentDecryptorState) => { - if (state > ContentDecryptorState.Initializing) { + const updateCodecSupportOnStateChange = (state: IContentDecryptorStateData) => { + if (state.name > ContentDecryptorState.Initializing) { const manifest = this._currentContentInfo?.manifest; if (isNullOrUndefined(manifest)) { return; @@ -1254,31 +1251,38 @@ export default class MultiThreadContentInitializer extends ContentInitializer { this.trigger("decipherabilityUpdate", manUpdates); }); contentDecryptor.addEventListener("stateChange", (state) => { - if (state === ContentDecryptorState.WaitingForAttachment) { - mediaSourceStatus.onUpdate( - (currStatus, stopListening) => { - if (currStatus === MediaSourceInitializationStatus.Nothing) { - mediaSourceStatus.setValue(MediaSourceInitializationStatus.AttachNow); - } else if (currStatus === MediaSourceInitializationStatus.Attached) { - stopListening(); - if (state === ContentDecryptorState.WaitingForAttachment) { - contentDecryptor.attach(); + switch (state.name) { + case ContentDecryptorState.Error: + this._onFatalError(state.payload); + break; + + case ContentDecryptorState.WaitingForAttachment: + mediaSourceStatus.onUpdate( + (currStatus, stopListening) => { + if (currStatus === MediaSourceInitializationStatus.Nothing) { + mediaSourceStatus.setValue(MediaSourceInitializationStatus.AttachNow); + } else if (currStatus === MediaSourceInitializationStatus.Attached) { + stopListening(); + if (state.name === ContentDecryptorState.WaitingForAttachment) { + contentDecryptor.attach(); + } } - } - }, - { clearSignal: cancelSignal, emitCurrentValue: true }, - ); - } else if (state === ContentDecryptorState.ReadyForContent) { - drmStatusRef.setValue({ - initializationState: { type: "initialized", value: null }, - drmSystemId: contentDecryptor.systemId, - }); - contentDecryptor.removeEventListener("stateChange"); - } - }); + }, + { clearSignal: cancelSignal, emitCurrentValue: true }, + ); + break; - contentDecryptor.addEventListener("error", (error) => { - this._onFatalError(error); + case ContentDecryptorState.ReadyForContent: + drmStatusRef.setValue({ + type: "initialized", + value: { + drmSystemId: state.payload.systemId, + canFilterProtectionData: state.payload.canFilterProtectionData, + failOnEncryptedAfterClear: state.payload.failOnEncryptedAfterClear, + }, + }); + break; + } }); contentDecryptor.addEventListener("warning", (error) => { @@ -1593,16 +1597,17 @@ export default class MultiThreadContentInitializer extends ContentInitializer { mediaElement: IMediaElement; textDisplayer: ITextDisplayer | null; playbackObserver: IMediaElementPlaybackObserver; - drmInitializationStatus: IReadOnlySharedReference; + drmInitializationStatus: IReadOnlySharedReference; mediaSourceStatus: IReadOnlySharedReference; }): boolean { if (this._currentContentInfo === null || this._currentContentInfo.manifest === null) { return false; } const drmInitStatus = parameters.drmInitializationStatus.getValue(); - if (drmInitStatus.initializationState.type !== "initialized") { + if (drmInitStatus.type !== "initialized") { return false; } + const drmPayload = drmInitStatus.value; const msInitStatus = parameters.mediaSourceStatus.getValue(); if (msInitStatus !== MediaSourceInitializationStatus.Attached) { return false; @@ -1641,7 +1646,9 @@ export default class MultiThreadContentInitializer extends ContentInitializer { value: { initialTime, initialObservation: sentInitialObservation, - drmSystemId: drmInitStatus.drmSystemId, + drmSystemId: drmPayload.drmSystemId, + canFilterProtectionData: drmPayload.canFilterProtectionData, + failOnEncryptedAfterClear: drmPayload.failOnEncryptedAfterClear, enableFastSwitching, onCodecSwitch, }, @@ -1992,17 +1999,6 @@ const enum MediaSourceInitializationStatus { Attached, } -interface IDrmInitializationStatus { - /** Current initialization state the decryption logic is in. */ - initializationState: IDecryptionInitializationState; - /** - * If set, corresponds to the hex string describing the current key system - * used. - * `undefined` if unknown or if it does not apply. - */ - drmSystemId: string | undefined; -} - /** Initialization steps to add decryption capabilities to an `HTMLMediaElement`. */ type IDecryptionInitializationState = /** @@ -2035,7 +2031,27 @@ type IDecryptionInitializationState = */ | { type: "initialized"; - value: null; + value: { + /** + * If set, corresponds to the hex string describing the current key system + * used. + * `undefined` if unknown. + */ + drmSystemId: string | undefined; + /** + * If `true`, protection data as found in the content can be manipulated so + * e.g. only the data linked to the given systemId may be communicated. + * + * If `false` the full extent of the protection data, in exactly the way it + * has been found in the content, should be communicated. + */ + canFilterProtectionData: boolean; + /** + * if `true`, the current device is known to not be able to begin playback of + * encrypted content if there's already clear content playing. + */ + failOnEncryptedAfterClear: boolean; + }; }; function formatSourceBufferError(error: unknown): SourceBufferError { diff --git a/src/main_thread/init/utils/initialize_content_decryption.ts b/src/main_thread/init/utils/initialize_content_decryption.ts index a4659946c0..a2200df43b 100644 --- a/src/main_thread/init/utils/initialize_content_decryption.ts +++ b/src/main_thread/init/utils/initialize_content_decryption.ts @@ -9,7 +9,7 @@ import TaskCanceller from "../../../utils/task_canceller"; import type { CancellationSignal } from "../../../utils/task_canceller"; import { ContentDecryptorState } from "../../decrypt"; import type IContentDecryptor from "../../decrypt"; -import type { IProcessedProtectionData } from "../../decrypt"; +import type { IContentDecryptorStateData, IProcessedProtectionData } from "../../decrypt"; /** * Initialize content decryption capabilities on the given `HTMLMediaElement`. @@ -48,7 +48,7 @@ export default function initializeContentDecryption( }, cancelSignal: CancellationSignal, ): { - statusRef: IReadOnlySharedReference; + statusRef: IReadOnlySharedReference; contentDecryptor: | { enabled: true; @@ -67,10 +67,10 @@ export default function initializeContentDecryption( const decryptorCanceller = new TaskCanceller(); decryptorCanceller.linkToSignal(cancelSignal); - const drmStatusRef = new SharedReference( + const drmStatusRef = new SharedReference( { - initializationState: { type: "uninitialized", value: null }, - drmSystemId: undefined, + type: "uninitialized", + value: null, }, cancelSignal, ); @@ -84,8 +84,8 @@ export default function initializeContentDecryption( log.debug("Init: Creating ContentDecryptor"); const contentDecryptor = new ContentDecryptor(mediaElement, keySystems); - const onStateChange = (state: ContentDecryptorState) => { - if (state > ContentDecryptorState.Initializing) { + const onStateChange = (stateData: IContentDecryptorStateData) => { + if (stateData.name > ContentDecryptorState.Initializing) { callbacks.onCodecSupportUpdate?.(); contentDecryptor.removeEventListener("stateChange", onStateChange); } @@ -93,40 +93,45 @@ export default function initializeContentDecryption( contentDecryptor.addEventListener("stateChange", onStateChange); contentDecryptor.addEventListener("stateChange", (state) => { - if (state === ContentDecryptorState.WaitingForAttachment) { - const isMediaLinked = new SharedReference(false); - isMediaLinked.onUpdate( - (isAttached, stopListening) => { - if (isAttached) { - stopListening(); - if (state === ContentDecryptorState.WaitingForAttachment) { - contentDecryptor.attach(); + switch (state.name) { + case ContentDecryptorState.Error: + decryptorCanceller.cancel(); + callbacks.onError(state.payload); + break; + + case ContentDecryptorState.WaitingForAttachment: + const isMediaLinked = new SharedReference(false); + isMediaLinked.onUpdate( + (isAttached, stopListening) => { + if (isAttached) { + stopListening(); + if (state.name === ContentDecryptorState.WaitingForAttachment) { + contentDecryptor.attach(); + } } - } - }, - { clearSignal: decryptorCanceller.signal }, - ); - drmStatusRef.setValue({ - initializationState: { + }, + { clearSignal: decryptorCanceller.signal }, + ); + drmStatusRef.setValue({ type: "awaiting-media-link", value: { isMediaLinked }, - }, - drmSystemId: contentDecryptor.systemId, - }); - } else if (state === ContentDecryptorState.ReadyForContent) { - drmStatusRef.setValue({ - initializationState: { type: "initialized", value: null }, - drmSystemId: contentDecryptor.systemId, - }); - contentDecryptor.removeEventListener("stateChange"); + }); + break; + + case ContentDecryptorState.ReadyForContent: + drmStatusRef.setValue({ + type: "initialized", + value: { + drmSystemId: state.payload.systemId, + canFilterProtectionData: state.payload.canFilterProtectionData, + failOnEncryptedAfterClear: state.payload.failOnEncryptedAfterClear, + }, + }); + drmStatusRef.finish(); + break; } }); - contentDecryptor.addEventListener("error", (error) => { - decryptorCanceller.cancel(); - callbacks.onError(error); - }); - contentDecryptor.addEventListener("warning", (error) => { callbacks.onWarning(error); }); @@ -149,7 +154,7 @@ export default function initializeContentDecryption( }; function createEmeDisabledReference(errMsg: string): { - statusRef: IReadOnlySharedReference; + statusRef: IReadOnlySharedReference; contentDecryptor: { enabled: false; value: EncryptedMediaError; @@ -157,26 +162,18 @@ export default function initializeContentDecryption( } { const err = new EncryptedMediaError("MEDIA_IS_ENCRYPTED_ERROR", errMsg); const ref = new SharedReference({ - initializationState: { type: "initialized" as const, value: null }, - drmSystemId: undefined, + type: "initialized" as const, + value: { + drmSystemId: undefined, + canFilterProtectionData: true, + failOnEncryptedAfterClear: false, + }, }); ref.finish(); // We know that no new value will be triggered return { statusRef: ref, contentDecryptor: { enabled: false, value: err } }; } } -/** Status of content decryption initialization. */ -interface IDrmInitializationStatus { - /** Current initialization state the decryption logic is in. */ - initializationState: IDecryptionInitializationState; - /** - * If set, corresponds to the hex string describing the current key system - * used. - * `undefined` if unknown or if it does not apply. - */ - drmSystemId: string | undefined; -} - /** Initialization steps to add decryption capabilities to an `HTMLMediaElement`. */ type IDecryptionInitializationState = /** @@ -205,4 +202,27 @@ type IDecryptionInitializationState = * The `MediaSource` or media url can be linked AND segments can be pushed to * the `HTMLMediaElement` on which decryption capabilities were wanted. */ - | { type: "initialized"; value: null }; + | { + type: "initialized"; + value: { + /** + * If set, corresponds to the hex string describing the current key system + * used. + * `undefined` if unknown. + */ + drmSystemId: string | undefined; + /** + * If `true`, protection data as found in the content can be manipulated so + * e.g. only the data linked to the given systemId may be communicated. + * + * If `false` the full extent of the protection data, in exactly the way it + * has been found in the content, should be communicated. + */ + canFilterProtectionData: boolean; + /** + * if `true`, the current device is known to not be able to begin playback of + * encrypted content if there's already clear content playing. + */ + failOnEncryptedAfterClear: boolean; + }; + }; diff --git a/src/main_thread/init/utils/update_manifest_codec_support.ts b/src/main_thread/init/utils/update_manifest_codec_support.ts index 61049207f1..3ac862830f 100644 --- a/src/main_thread/init/utils/update_manifest_codec_support.ts +++ b/src/main_thread/init/utils/update_manifest_codec_support.ts @@ -100,7 +100,7 @@ export function updateManifestCodecSupport( // encrypted codec is supported isSupportedEncrypted: true, }; - } else if (contentDecryptor.getState() === ContentDecryptorState.Initializing) { + } else if (contentDecryptor.getState().name === ContentDecryptorState.Initializing) { newData = { isSupportedClear: true, isSupportedEncrypted: undefined, diff --git a/src/multithread_types.ts b/src/multithread_types.ts index a9460942cd..3c73d8e60f 100644 --- a/src/multithread_types.ts +++ b/src/multithread_types.ts @@ -218,6 +218,19 @@ export interface IStartPreparedContentMessageValue { * optimizations. */ drmSystemId: string | undefined; + /** + * If `true`, protection data as found in the content can be manipulated so + * e.g. only the data linked to the given systemId may be communicated. + * + * If `false` the full extent of the protection data, in exactly the way it + * has been found in the content, should be communicated. + */ + canFilterProtectionData: boolean; + /** + * If `true`, the current device is known to not be able to begin playback of + * encrypted content if there's already clear content playing. + */ + failOnEncryptedAfterClear: boolean; /** * Enable/Disable fastSwitching: allow to replace lower-quality segments by * higher-quality ones to have a faster transition. diff --git a/src/public_types.ts b/src/public_types.ts index 33d7ef7ea3..ffba531406 100644 --- a/src/public_types.ts +++ b/src/public_types.ts @@ -582,6 +582,12 @@ export interface IKeySystemOption { */ disableMediaKeysAttachmentLock?: boolean; + /** + * If `true`, the current device is known to not be able to begin playback of + * encrypted content if there's already clear content playing. + */ + failOnEncryptedAfterClear?: boolean | undefined; + /** * Behavior the RxPlayer should have when one of the key has the * `MediaKeyStatus` `"internal-error"`.