diff --git a/docs/tutorials/02-plugins.md b/docs/tutorials/02-plugins.md index d8a471d5..cc68e5f1 100644 --- a/docs/tutorials/02-plugins.md +++ b/docs/tutorials/02-plugins.md @@ -12,8 +12,10 @@ The full interface is as follows: - `onPlayerInfoUpdated` - `onManifestLoaded` - `onManifestParseError` -- `onQualityChangeRequested` -- `onQualityChangedRendered` +- `onQualityChangeRequested` (deprecated: use `onDownloadQualityChange`) +- `onQualityChangeRendered` (deprecated: use `onPlaybackQualityChange`) +- `onDownloadQualityChange` +- `onPlaybackQualityChange` - `onSubtitlesLoadError` - `onSubtitlesTimeout` - `onSubtitlesXMLError` diff --git a/src/playbackstrategy/msestrategy.js b/src/playbackstrategy/msestrategy.js index 32227238..47e223f9 100644 --- a/src/playbackstrategy/msestrategy.js +++ b/src/playbackstrategy/msestrategy.js @@ -10,6 +10,7 @@ import DOMHelpers from "../domhelpers" import Utils from "../utils/playbackutils" import convertTimeRangesToArray from "../utils/mse/convert-timeranges-to-array" import { ManifestType } from "../models/manifesttypes" +import setPropertyPath from "../utils/setpropertypath" const DEFAULT_SETTINGS = { liveDelay: 0, @@ -85,6 +86,10 @@ function MSEStrategy( [MediaKinds.AUDIO]: undefined, [MediaKinds.VIDEO]: undefined, }, + playbackQuality: { + [MediaKinds.AUDIO]: undefined, + [MediaKinds.VIDEO]: undefined, + }, playbackBitrate: undefined, bufferLength: undefined, latency: undefined, @@ -330,6 +335,8 @@ function MSEStrategy( mediaPlayer.setMediaDuration(Number.MAX_SAFE_INTEGER) } + DebugTool.info("Stream initialised") + if (mediaPlayer.getActiveStream()?.getHasVideoTrack()) { dispatchDownloadQualityChangeForKind(MediaKinds.VIDEO) dispatchMaxQualityChangeForKind(MediaKinds.VIDEO) @@ -378,6 +385,43 @@ function MSEStrategy( const switchToPart = ` to ${qualityIndex} (${(bitrateInBps / 1000).toFixed(0)} kbps)` DebugTool.info(`${abrChangePart}${switchFromPart}${switchToPart}`) + + Plugins.interface.onDownloadQualityChange({ + type: "downloadqualitychange", + detail: { + mediaType: kind, + currentBitrateInBps: bitrateInBps, + currentQualityIndex: qualityIndex, + previousBitrateInBps: prevBitrateInBps, + previousQualityIndex: prevQualityIndex, + }, + }) + } + + function dispatchPlaybackQualityChangeForKind(kind, { qualityIndex } = {}) { + const { qualityIndex: previousQualityIndex, bitrateInBps: previousBitrateInBps } = + playerMetadata.playbackQuality[kind] ?? {} + + if (previousQualityIndex === qualityIndex) { + return + } + + const bitrateInBps = playbackBitrateForRepresentationIndex(qualityIndex, kind) + + playerMetadata.playbackQuality[kind] = { bitrateInBps, qualityIndex } + + DebugTool.dynamicMetric(`${kind}-playback-quality`, [qualityIndex, bitrateInBps]) + + Plugins.interface.onPlaybackQualityChange({ + type: "playbackqualitychange", + detail: { + mediaType: kind, + previousBitrateInBps, + previousQualityIndex, + currentBitrateInBps: bitrateInBps, + currentQualityIndex: qualityIndex, + }, + }) } function dispatchMaxQualityChangeForKind(kind) { @@ -428,23 +472,16 @@ function MSEStrategy( } function onQualityChangeRendered(event) { - if ( - event.newQuality !== undefined && - (event.mediaType === MediaKinds.AUDIO || event.mediaType === MediaKinds.VIDEO) - ) { - const { mediaType, newQuality } = event - - DebugTool.dynamicMetric(`${mediaType}-playback-quality`, [ - newQuality, - playbackBitrateForRepresentationIndex(newQuality, mediaType), - ]) + const { mediaType, newQuality } = event + if (newQuality !== undefined && (mediaType === MediaKinds.AUDIO || mediaType === MediaKinds.VIDEO)) { + dispatchPlaybackQualityChangeForKind(mediaType, { qualityIndex: newQuality }) dispatchMaxQualityChangeForKind(mediaType) } emitPlayerInfo() - Plugins.interface.onQualityChangedRendered(event) + Plugins.interface.onQualityChangeRendered(event) } /** @@ -1021,17 +1058,22 @@ function MSEStrategy( * Set constrained audio or video bitrate */ function setBitrateConstraint(mediaKind, minBitrateKbps, maxBitrateKbps) { + if (mediaKind !== MediaKinds.AUDIO && mediaKind !== MediaKinds.VIDEO) { + throw new TypeError(`Bitrate constraint not supported for this media kind. (got ${mediaKind})`) + } + + if (mediaPlayer == null) { + setPropertyPath(playerSettings, ["streaming", "abr", "minBitrate", mediaKind], minBitrateKbps) + setPropertyPath(playerSettings, ["streaming", "abr", "maxBitrate", mediaKind], maxBitrateKbps) + + return + } + mediaPlayer.updateSettings({ streaming: { abr: { - minBitrate: { - audio: mediaKind === MediaKinds.AUDIO ? minBitratKbps : -1, - video: mediaKind === MediaKinds.VIDEO ? minBitrateKbps : -1, - }, - maxBitrate: { - audio: mediaKind === MediaKinds.AUDIO ? maxBitrateKbps : -1, - video: mediaKind === MediaKinds.VIDEO ? maxBitrateKbps : -1, - }, + minBitrate: { [mediaKind]: minBitrateKbps }, + maxBitrate: { [mediaKind]: maxBitrateKbps }, }, }, }) diff --git a/src/playbackstrategy/msestrategy.test.js b/src/playbackstrategy/msestrategy.test.js index e7b0b48f..3e23a382 100644 --- a/src/playbackstrategy/msestrategy.test.js +++ b/src/playbackstrategy/msestrategy.test.js @@ -1923,12 +1923,34 @@ describe("Media Source Extensions Playback Strategy", () => { }) describe("Playback bitrate", () => { + it("setBitrateConstraint should apply to an uninitialised player", () => { + const mseStrategy = MSEStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) + + mseStrategy.setBitrateConstraint("video", 100, 200) + + mseStrategy.load() + + expect(mockDashInstance.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + streaming: expect.objectContaining({ + abr: expect.objectContaining({ minBitrate: { video: 100 }, maxBitrate: { video: 200 } }), + }), + }) + ) + }) + it("setBitrateConstraint should call media player updateSettings", () => { const mseStrategy = MSEStrategy(mockMediaSources, MediaKinds.VIDEO, playbackElement) mseStrategy.load(null, 0) mseStrategy.setBitrateConstraint("video", 100, 200) - expect(mockDashInstance.updateSettings).toHaveBeenCalled() + expect(mockDashInstance.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + streaming: expect.objectContaining({ + abr: expect.objectContaining({ minBitrate: { video: 100 }, maxBitrate: { video: 200 } }), + }), + }) + ) }) it("getPlaybackBitrate returns the current playback bitrate in kbps", () => { diff --git a/src/plugins.js b/src/plugins.js index 6a6bc77a..b4715135 100644 --- a/src/plugins.js +++ b/src/plugins.js @@ -40,8 +40,9 @@ export default { onPlayerInfoUpdated: (evt) => callOnAllPlugins("onPlayerInfoUpdated", evt), onManifestLoaded: (manifest) => callOnAllPlugins("onManifestLoaded", manifest), onManifestParseError: (evt) => callOnAllPlugins("onManifestParseError", evt), + onDownloadQualityChange: (evt) => callOnAllPlugins("onDownloadQualityChange", evt), onQualityChangeRequested: (evt) => callOnAllPlugins("onQualityChangeRequested", evt), - onQualityChangedRendered: (evt) => callOnAllPlugins("onQualityChangedRendered", evt), + onQualityChangeRendered: (evt) => callOnAllPlugins("onQualityChangeRendered", evt), onSubtitlesLoadError: (evt) => callOnAllPlugins("onSubtitlesLoadError", evt), onSubtitlesTimeout: (evt) => callOnAllPlugins("onSubtitlesTimeout", evt), onSubtitlesXMLError: (evt) => callOnAllPlugins("onSubtitlesXMLError", evt), @@ -50,6 +51,7 @@ export default { onSubtitlesDynamicLoadError: (evt) => callOnAllPlugins("onSubtitlesDynamicLoadError", evt), onFragmentContentLengthMismatch: (evt) => callOnAllPlugins("onFragmentContentLengthMismatch", evt), onQuotaExceeded: (evt) => callOnAllPlugins("onQuotaExceeded", evt), + onPlaybackQualityChange: (evt) => callOnAllPlugins("onPlaybackQualityChange", evt), onPlaybackRateChanged: (evt) => callOnAllPlugins("onPlaybackRateChanged", evt), onPlaybackFrozen: (evt) => callOnAllPlugins("onPlaybackFrozen", evt), }, diff --git a/src/utils/setpropertypath.ts b/src/utils/setpropertypath.ts new file mode 100644 index 00000000..ab56eae7 --- /dev/null +++ b/src/utils/setpropertypath.ts @@ -0,0 +1,38 @@ +export default function setPropertyPath( + target: Record, + path: Key | Key[], + value: unknown +) { + if (target == null || (typeof target !== "object" && typeof target !== "function")) { + throw new TypeError(`Target must be an object. (got ${target as any})`) + } + + const keysDesc = typeof path === "string" ? path.split(".") : path + + if (keysDesc.length === 0) { + throw new TypeError("Empty path provided. (got [])") + } + + const key = keysDesc.shift() + + if (key == null) { + throw new TypeError(`Cannot index object with key. (got key '${key}')`) + } + + if (keysDesc.length === 0) { + target[key as Key] = value + + return + } + + let next = target[key as Key] + + if (next != null && typeof next !== "object" && typeof next !== "function") { + throw new TypeError(`Cannot assign to primitive value. (got '${next as string}')`) + } + + next ??= {} + target[key as Key] = next + + setPropertyPath(next as Record, keysDesc as Key | Key[], value) +}