diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6ef21..a8cf663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Changed + +- Yospace SDK to v3.6.9 + +### Fixed + +- Parsing non-Yospace ID3 tags and passing those to the Yospace SDK + +## [2.5.0] - 2024-07-10 + +### Added + +- `mode` argument to `getCurrentTime` to enable fetching absolute time including ad durations +- `mode` argument to `getDuration` to enable fetching absolute duration including ad durations +- ad immunity feature: + + The user will become immune to ad breaks for a duration upon + fully watching an ad break. + + Ad breaks played over or seeked past during immunity will be marked + as deactivated, making the user permanently immune to them. + + Post-roll ads and ads with unknown positioning are excluded from ad immunity. + + By default, pre-rolls are also excluded, since the user needs to finish + an ad break to enter an ad immunity period. + + `setAdImmunityConfig(options: AdImmunityConfig): void;` + + Returns the current ad immunity configuration. + + `getAdImmunityConfig(): AdImmunityConfig;` + + Returns a boolean value indicating if the user is currently immune to ad breaks + `isAdImmunityActive(): boolean;` + + Immediately starts an ad immunity period, if ad immunity config exists. This method does nothing if ad immunity is already active. + `startAdImmunity(): void;` + + Immediately ends an ongoing ad immunity period, before it would naturally expire + `endAdImmunity(): void;` + +- ad immunity events to `YospacePlayerEvent` enum + ## [2.4.0] - 2024-06-21 ### Added @@ -334,7 +378,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Initial yospace integration -[unreleased]: https://github.com/bitmovin/bitmovin-player-web-integrations-yospace/compare/v2.4.0...HEAD +[unreleased]: https://github.com/bitmovin/bitmovin-player-web-integrations-yospace/compare/v2.5.0...HEAD +[2.5.0]: https://github.com/bitmovin/bitmovin-player-web-integrations-yospace/compare/v2.4.0...v2.5.0 [2.4.0]: https://github.com/bitmovin/bitmovin-player-web-integrations-yospace/compare/v2.3.1...v2.4.0 [2.3.1]: https://github.com/bitmovin/bitmovin-player-web-integrations-yospace/compare/v2.3.0...v2.3.1 [2.3.0]: https://github.com/bitmovin/bitmovin-player-web-integrations-yospace/compare/v2.2.0...v2.3.0 diff --git a/README.md b/README.md index daa05d3..ec7f9c0 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,8 @@ This integration completely encapsulates the usage of Yospace. After creating th 2. Install the Bitmovin Player Yospace Integration: `npm i -S @bitmovin/player-integration-yospace` 3. Import the `BitmovinYospacePlayer` into your code: `import { BitmovinYospacePlayer } from '@bitmovin/player-integration-yospace';` 4. Import the Bitmovin `Player` core into your code: `import { Player } from 'bitmovin-player/modules/bitmovinplayer-core';` -5. Add the relevant Bitmovin Player modules to the `Player` object using the static `Player.addModule(...)` API -6. Create a new player instance, and pass the BitmovinPlayerStaticAPI to it: `new BitmovinYospacePlayer(Player, container, config)` +5. Add the relevant Bitmovin Player modules to the `Player` object using the static `Player.addModule(...)` API. Please note that `bitmovinplayer-advertising-core` and `bitmovinplayer-advertising-bitmovin` are required by this integration and must be provided. +6. Create a new player instance, and pass the `BitmovinPlayerStaticAPI` to it: `new BitmovinYospacePlayer(Player, container, config)` 7. Load a `YospaceSourceConfig` with your Yospace HLS/DASH URL. It's a `PlayerConfig` with Yospace-specific extension. Most important extension is the `assetType`, which needs to be set. In addition, HLS is picked before DASH, so if the user wants to play a dash stream the hls config has to be omitted. ```ts diff --git a/package-lock.json b/package-lock.json index b8ad3b6..73924f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@bitmovin/player-integration-yospace", - "version": "2.4.0", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@bitmovin/player-integration-yospace", - "version": "2.4.0", + "version": "2.5.0", "license": "MIT", "dependencies": { - "@yospace/admanagement-sdk": "3.6.0", + "@yospace/admanagement-sdk": "3.6.9", "fast-safe-stringify": "^2.0.7", "process": "^0.11.10", "stream-browserify": "^3.0.0" @@ -36,7 +36,6 @@ "webpack-merge": "^5.8.0" }, "peerDependencies": { - "@yospace/admanagement-sdk": "3.6.0", "bitmovin-player": "^8.157.0" } }, @@ -1008,9 +1007,9 @@ "dev": true }, "node_modules/@yospace/admanagement-sdk": { - "version": "3.6.0", - "resolved": "https://yospacerepo.jfrog.io/artifactory/api/npm/javascript-sdk/@yospace/admanagement-sdk/-/@yospace/admanagement-sdk-3.6.0.tgz", - "integrity": "sha512-TwjT7iaoQ9SCyN7uKfmCpj7n3zjIObXnyvEUGQaes0VoYvuaYMHciCpzjLg82gO0gGQ59tCpg6jgBOxWhsgCpA==", + "version": "3.6.9", + "resolved": "https://yospacerepo.jfrog.io/artifactory/api/npm/javascript-sdk/@yospace/admanagement-sdk/-/@yospace/admanagement-sdk-3.6.9.tgz", + "integrity": "sha512-sW7rYt00lHeIemcsf1EkaygAARFsfOCM48Sj4XS7oAJJs1pxa3PjL7QN7R5Vd+fU/M7iApX0fuHsqvgAjIedCA==", "dependencies": { "sax": "1.2.4" } @@ -1897,6 +1896,18 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.17.0.tgz", @@ -2052,18 +2063,6 @@ } } }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", @@ -3614,18 +3613,6 @@ "node": ">=8" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -4620,13 +4607,10 @@ } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -5925,12 +5909,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/yaml": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz", @@ -6758,9 +6736,9 @@ "dev": true }, "@yospace/admanagement-sdk": { - "version": "3.6.0", - "resolved": "https://yospacerepo.jfrog.io/artifactory/api/npm/javascript-sdk/@yospace/admanagement-sdk/-/@yospace/admanagement-sdk-3.6.0.tgz", - "integrity": "sha512-TwjT7iaoQ9SCyN7uKfmCpj7n3zjIObXnyvEUGQaes0VoYvuaYMHciCpzjLg82gO0gGQ59tCpg6jgBOxWhsgCpA==", + "version": "3.6.9", + "resolved": "https://yospacerepo.jfrog.io/artifactory/api/npm/javascript-sdk/@yospace/admanagement-sdk/-/@yospace/admanagement-sdk-3.6.9.tgz", + "integrity": "sha512-sW7rYt00lHeIemcsf1EkaygAARFsfOCM48Sj4XS7oAJJs1pxa3PjL7QN7R5Vd+fU/M7iApX0fuHsqvgAjIedCA==", "requires": { "sax": "1.2.4" } @@ -7428,6 +7406,12 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, "eslint": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.17.0.tgz", @@ -7505,12 +7489,6 @@ "ms": "2.1.2" } }, - "escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true - }, "eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", @@ -8679,15 +8657,6 @@ } } }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -9393,13 +9362,10 @@ } }, "semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true }, "send": { "version": "0.18.0", @@ -10339,12 +10305,6 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "yaml": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.1.tgz", diff --git a/package.json b/package.json index 35ab011..fd9c3ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitmovin/player-integration-yospace", - "version": "2.4.0", + "version": "2.5.0", "description": "Yospace integration for the Bitmovin Player", "main": "./dist/js/bitmovin-player-yospace.js", "types": "./dist/js/main.d.ts", @@ -35,13 +35,12 @@ "author": "Bitmovin", "license": "MIT", "dependencies": { - "@yospace/admanagement-sdk": "3.6.0", + "@yospace/admanagement-sdk": "3.6.9", "fast-safe-stringify": "^2.0.7", "process": "^0.11.10", "stream-browserify": "^3.0.0" }, "peerDependencies": { - "@yospace/admanagement-sdk": "3.6.0", "bitmovin-player": "^8.157.0" }, "devDependencies": { diff --git a/src/ts/BitmovinYospacePlayer.ts b/src/ts/BitmovinYospacePlayer.ts index 51cc151..de46ba7 100644 --- a/src/ts/BitmovinYospacePlayer.ts +++ b/src/ts/BitmovinYospacePlayer.ts @@ -50,6 +50,7 @@ import { YospacePlayerType, YospacePolicyErrorCode, YospaceSourceConfig, + AdImmunityConfig, } from './BitmovinYospacePlayerAPI'; import { Logger } from './Logger'; import { BitmovinYospaceHelper } from './BitmovinYospaceHelper'; @@ -569,5 +570,25 @@ export class BitmovinYospacePlayer implements BitmovinYospacePlayerAPI { return this.player.getAspectRatio(); } + getAdImmunityConfig() { + return this.player.getAdImmunityConfig(); + } + + isAdImmunityActive() { + return this.player.isAdImmunityActive(); + } + + startAdImmunity() { + this.player.startAdImmunity(); + } + + endAdImmunity() { + this.player.endAdImmunity(); + } + + setAdImmunityConfig(options: AdImmunityConfig) { + this.player.setAdImmunityConfig(options); + } + readonly drm: DrmAPI; } diff --git a/src/ts/BitmovinYospacePlayerAPI.ts b/src/ts/BitmovinYospacePlayerAPI.ts index ab4c781..a852872 100644 --- a/src/ts/BitmovinYospacePlayerAPI.ts +++ b/src/ts/BitmovinYospacePlayerAPI.ts @@ -69,6 +69,42 @@ export interface BitmovinYospacePlayerAPI extends PlayerAPI { getDuration(mode?: TimeMode): number; forceSeek(time: number, issuer?: string): boolean; + + /** + * Provide a duration in seconds greater than 0 to enable the ad immunity feature. + * The user will become immune to ad breaks for the duration upon + * fully watching an ad break. + * + * Ad breaks played over or seeked past during immunity will be marked + * as deactivated, making the user permanently immune to those breaks. + * + * Post-rolls are excluded from ad immunity + * + * Pre-roll ads are excluded from ad immunity as at least one ad break needs to be + * watched completely + */ + setAdImmunityConfig(options: AdImmunityConfig): void; + + /** + * Returns the current ad immunity configuration + */ + getAdImmunityConfig(): AdImmunityConfig; + + /** + * Returns a boolean value indicating if the user is currently immune to ad breaks + */ + isAdImmunityActive(): boolean; + + /** + * Immediately starts an ad immunity period, if ad immunity config exists. This method does nothing if ad immunity is already active. + * To refresh an ad immunity period, first call endAdImmunity followed by startAdImmunity. + */ + startAdImmunity(): void; + + /** + * Immediately ends an ongoing ad immunity period, before it would naturally expire + */ + endAdImmunity(): void; } export interface YospaceSourceConfig extends SourceConfig { @@ -84,6 +120,7 @@ export interface TruexConfiguration { export interface YospaceAdBreak extends AdBreak { duration: number; position?: YospaceAdBreakPosition; + active: boolean; } export interface YospaceAdBreakEvent extends PlayerEventBase { @@ -164,6 +201,9 @@ export enum YospacePlayerEvent { PolicyError = 'policyerror', TruexAdFree = 'truexadfree', TruexAdBreakFinished = 'truexadbreakfinished', + AdImmunityConfigured = 'adimmunityconfigured', + AdImmunityStarted = 'adimmunitystarted', + AdImmunityEnded = 'adimmunityended', } export enum YospaceErrorCode { @@ -212,3 +252,28 @@ export interface YospaceEventBase { export interface YospacePlayerEventCallback { (event: PlayerEventBase | YospaceEventBase): void; } + +export interface AdImmunityConfiguredEvent extends YospaceEventBase { + type: YospacePlayerEvent.AdImmunityConfigured; + config: AdImmunityConfig; +} + +export interface AdImmunityStartedEvent extends YospaceEventBase { + type: YospacePlayerEvent.AdImmunityStarted; + duration: number; +} + +export interface AdImmunityEndedEvent extends YospaceEventBase { + type: YospacePlayerEvent.AdImmunityEnded; +} + +/** + * @description Ad Immunity Configuration Object + * @property duration - a number indicating the duration of the ad immunity period. 0 disables the feature. + * @property adBreakCheckOffset - a number indicating how far ahead ad immunity should look for ad breaks + * to skip past, in order to mitigate ad frames being displayed before they have time to be seeked past. + */ +export interface AdImmunityConfig { + duration: number; + adBreakCheckOffset?: number; +} diff --git a/src/ts/BitmovinYospacePlayerPolicy.ts b/src/ts/BitmovinYospacePlayerPolicy.ts index 18805fa..a450253 100644 --- a/src/ts/BitmovinYospacePlayerPolicy.ts +++ b/src/ts/BitmovinYospacePlayerPolicy.ts @@ -1,5 +1,5 @@ import { LinearAd } from 'bitmovin-player'; -import { BitmovinYospacePlayerAPI, BitmovinYospacePlayerPolicy } from './BitmovinYospacePlayerAPI'; +import { BitmovinYospacePlayerAPI, BitmovinYospacePlayerPolicy, YospaceAdBreak } from './BitmovinYospacePlayerAPI'; export class DefaultBitmovinYospacePlayerPolicy implements BitmovinYospacePlayerPolicy { private player: BitmovinYospacePlayerAPI; @@ -15,10 +15,11 @@ export class DefaultBitmovinYospacePlayerPolicy implements BitmovinYospacePlayer canSeekTo(seekTarget: number): number { const currentTime = this.player.getCurrentTime(); - const adBreaks = this.player.ads.list(); + // @ts-expect-error the BitmovinYospacePlayerAPI interface is not up to date + const adBreaks: YospaceAdBreak[] = this.player.ads.list(); const skippedAdBreaks = adBreaks.filter((adBreak) => { - return adBreak.scheduleTime > currentTime && adBreak.scheduleTime < seekTarget; + return adBreak.active && adBreak.scheduleTime > currentTime && adBreak.scheduleTime < seekTarget; }); if (skippedAdBreaks.length > 0) { diff --git a/src/ts/InternalBitmovinYospacePlayer.ts b/src/ts/InternalBitmovinYospacePlayer.ts index 669e708..33300aa 100644 --- a/src/ts/InternalBitmovinYospacePlayer.ts +++ b/src/ts/InternalBitmovinYospacePlayer.ts @@ -52,6 +52,8 @@ import { import { DefaultBitmovinYospacePlayerPolicy } from './BitmovinYospacePlayerPolicy'; import { ArrayUtils } from 'bitmovin-player-ui/dist/js/framework/arrayutils'; import { + AdImmunityConfiguredEvent, + AdImmunityEndedEvent, BitmovinYospacePlayerAPI, BitmovinYospacePlayerPolicy, CompanionAdType, @@ -69,6 +71,8 @@ import { YospacePolicyErrorCode, YospacePolicyErrorEvent, YospaceSourceConfig, + AdImmunityStartedEvent, + AdImmunityConfig, } from './BitmovinYospacePlayerAPI'; import { YospacePlayerError } from './YospaceError'; import type { @@ -147,6 +151,15 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { private startSent: boolean; private lastTimeChangedTime = 0; + private adImmunityConfig: AdImmunityConfig = { + duration: 0, // 0 duration = disabled + }; + // Ad holiday offset for discovering upcoming ad breaks before an ad frame is shown to the user + private defaultAdBreakCheckOffset = 0.3; + + private adImmune = false; + private adImmunityCountDown: number | null = null; + private unpauseAfterSeek = false; constructor(containerElement: HTMLElement, player: PlayerAPI, yospaceConfig: YospaceConfiguration = {}) { this.yospaceConfig = yospaceConfig; @@ -251,11 +264,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { }; // convert start time (relative) to an absolute time - if ( - this.yospaceSourceConfig.assetType === YospaceAssetType.VOD && - clonedSource.options && - clonedSource.options.startOffset - ) { + if (this.yospaceSourceConfig.assetType === YospaceAssetType.VOD && clonedSource.options && clonedSource.options.startOffset) { clonedSource.options.startOffset = this.toAbsoluteTime(clonedSource.options.startOffset); Logger.log('startOffset adjusted to: ' + clonedSource.options.startOffset); } @@ -393,7 +402,25 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { return false; } + // set all breaks seeked past during immunity to inactive + // this prevents the default player policy from redirecting + // the seek target + if (this.adImmune) { + const currentTime = this.player.getCurrentTime(); + + this.session.getAdBreaksByType(BreakType.LINEAR).forEach((adBreak) => { + const breakStart = this.toMagicTime(toSeconds(adBreak.getStart())); + + // Check if break is being seeked past and deactivate it + if (breakStart > currentTime && breakStart <= time) { + Logger.log('[BitmovinYospacePlayer] Ad Immunity deactivated ad break during seek', adBreak); + adBreak.setInactive(); + } + }); + } + const allowedSeekTarget = this.playerPolicy.canSeekTo(time); + if (allowedSeekTarget !== time) { // cache original seek target this.cachedSeekTarget = time; @@ -436,25 +463,21 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { return this.player.getDuration(); } - return toSeconds( - (this.session as SessionVOD).getContentPositionForPlayhead(toMilliseconds(this.player.getDuration())) - ); + return toSeconds((this.session as SessionVOD).getContentPositionForPlayhead(toMilliseconds(this.player.getDuration()))); } /** * @deprecated Use {@link PlayerBufferAPI.getLevel} instead. */ getVideoBufferLength(): number | null { - return this.buffer.getLevel(this.player.exports.BufferType.ForwardDuration, this.player.exports.MediaType.Video) - .level; + return this.buffer.getLevel(this.player.exports.BufferType.ForwardDuration, this.player.exports.MediaType.Video).level; } /** * @deprecated Use {@link PlayerBufferAPI.getLevel} instead. */ getAudioBufferLength(): number | null { - return this.buffer.getLevel(this.player.exports.BufferType.ForwardDuration, this.player.exports.MediaType.Audio) - .level; + return this.buffer.getLevel(this.player.exports.BufferType.ForwardDuration, this.player.exports.MediaType.Audio).level; } getBufferedRanges(): TimeRange[] { @@ -548,7 +571,74 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { this.player.setPlaybackSpeed(this.playbackSpeed); } + setAdImmunityConfig(config: AdImmunityConfig) { + this.adImmunityConfig = { + ...this.adImmunityConfig, + ...config, + }; + Logger.log('[BitmovinYospacePlayer] Ad Immunity Configured:', this.adImmunityConfig); + this.handleYospaceEvent({ + timestamp: Date.now(), + type: YospacePlayerEvent.AdImmunityConfigured, + config, + }); + } + + getAdImmunityConfig() { + return this.adImmunityConfig; + } + + isAdImmunityActive() { + return this.adImmune; + } + + endAdImmunity() { + this.endAdImmunityPeriod(); + } + + startAdImmunity() { + this.startAdImmunityPeriod(); + } + // Helper + + private endAdImmunityPeriod() { + if (this.adImmunityCountDown !== null) { + window.clearTimeout(this.adImmunityCountDown); + this.adImmunityCountDown = null; + } + + if (!this.adImmune) return; + + this.adImmune = false; + Logger.log('[BitmovinYospacePlayer] Ad Immunity Ended'); + this.handleYospaceEvent({ + timestamp: Date.now(), + type: YospacePlayerEvent.AdImmunityEnded, + }); + } + + private startAdImmunityPeriod() { + // only start a timer if a duration has been configured, and ad immunity is not + // already active. Only enable for VOD content. + if (this.adImmune || this.yospaceSourceConfig.assetType !== YospaceAssetType.VOD) { + return; + } else if (this.adImmunityConfig.duration) { + this.adImmune = true; + + this.adImmunityCountDown = window.setTimeout(() => { + this.endAdImmunityPeriod(); + }, toMilliseconds(this.adImmunityConfig.duration)); + + Logger.log('[BitmovinYospacePlayer] Ad Immunity Started, duration', this.adImmunityConfig.duration); + this.handleYospaceEvent({ + timestamp: Date.now(), + type: YospacePlayerEvent.AdImmunityStarted, + duration: this.adImmunityConfig.duration, + }); + } + } + private isAdActive(): boolean { return Boolean(this.getCurrentAd()); } @@ -681,10 +771,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { ...mapToYospaceCompanionAd(currentAd.getCompanionAdsByType(ResourceType.STATIC), CompanionAdType.StaticResource), ...mapToYospaceCompanionAd(currentAd.getCompanionAdsByType(ResourceType.HTML), CompanionAdType.HtmlResource), ...mapToYospaceCompanionAd(currentAd.getCompanionAdsByType(ResourceType.IFRAME), CompanionAdType.IFrameResource), - ...mapToYospaceCompanionAd( - currentAd.getCompanionAdsByType(ResourceType.UNKNOWN), - CompanionAdType.UnknownResource - ), + ...mapToYospaceCompanionAd(currentAd.getCompanionAdsByType(ResourceType.UNKNOWN), CompanionAdType.UnknownResource), ]; const playerEvent = AdEventsFactory.createAdEvent( @@ -707,11 +794,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { private onAdFinished = () => { const currentAd = this.getCurrentAd(); - const playerEvent = AdEventsFactory.createAdEvent( - this.player, - this.player.exports.PlayerEvent.AdFinished, - currentAd - ); + const playerEvent = AdEventsFactory.createAdEvent(this.player, this.player.exports.PlayerEvent.AdFinished, currentAd); this.fireEvent(playerEvent); this.adStartedTimestamp = null; }; @@ -723,6 +806,8 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { this.fireEvent(playerEvent); + this.startAdImmunityPeriod(); + if (this.cachedSeekTarget) { this.seek(this.cachedSeekTarget, 'yospace-ad-skipping'); this.cachedSeekTarget = null; @@ -756,6 +841,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { ads: ysAdBreak.getAdverts().map(AdTranslator.mapYsAdvert), duration: toSeconds(ysAdBreak.getDuration()), position: ysAdBreak.getPosition() as YospaceAdBreakPosition, + active: ysAdBreak.isActive(), }; } @@ -771,9 +857,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { } private getAdBreaksBefore(position: number): AdBreak[] { - return this.adParts - .filter((part) => part.start < position && position >= part.end) - .map((element) => element.adBreak); + return this.adParts.filter((part) => part.start < position && position >= part.end).map((element) => element.adBreak); } private getAdDuration(ad: Advert): number { @@ -850,10 +934,14 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { this.session = null; } + this.endAdImmunityPeriod(); + if (this.dateRangeEmitter) { this.dateRangeEmitter.reset(); } + // should adImmunityConfig be reset here? If yes, it + // would require configuration for each new video start this.adParts = []; this.adStartedTimestamp = null; this.cachedSeekTarget = null; @@ -895,7 +983,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { this.fireEvent(event); } - private parseId3Tags(event: MetadataEvent, frames: Frame[] = []): TimedMetadata { + private parseId3Tags(event: MetadataEvent, frames: Frame[] = []): TimedMetadata | null { const charsToStr = (arr: [number]) => { return arr .filter((char) => char > 31 && char < 127) @@ -914,9 +1002,16 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { } metadata.frames.forEach((frame: any) => { - yospaceMetadataObject[frame.key] = charsToStr(frame.data); + if (Array.isArray(frame.data)) { + yospaceMetadataObject[frame.key] = charsToStr(frame.data); + } }); + if (!yospaceMetadataObject.YMID || !yospaceMetadataObject.YSEQ || !yospaceMetadataObject.YTYP || !yospaceMetadataObject.YDUR) { + // this was not a proper Yospace metadata but generic one and should not be passed to Yospace. + return null; + } + return TimedMetadata.createFromMetadata( /* ymid */ yospaceMetadataObject.YMID, @@ -931,7 +1026,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { ); } - private mapEmsgToId3Tags(event: MetadataEvent): TimedMetadata { + private mapEmsgToId3Tags(event: MetadataEvent): TimedMetadata | null { const metadata = event.metadata as any; const startTime = metadata.presentationTime ? metadata.presentationTime : (this.player.getCurrentTime() as any); @@ -948,18 +1043,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { return this.parseId3Tags(event, id3Frames); } catch (e) { Logger.warn(e); - return TimedMetadata.createFromMetadata( - /* ymid */ - null, - /* yseq */ - null, - /* ytyp */ - null, - /* ydur */ - null, - /* playhead */ - toMilliseconds(startTime) - ); + return null; } } else if (metadata.schemeIdUri === EmsgSchemeIdUri.V0_ID3_YOSPACE_PROPRIETARY) { const yospaceMetadataObject: any = {}; @@ -983,6 +1067,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { ); } else { Logger.warn('Yospace integration encountered metadata that it cannot parse'); + return null; } } @@ -1022,10 +1107,13 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { // need to stay in the SDK as there might be AdBreak Impressions and other beacons Yospace might need to trigger. // These ad breaks wouldn't be visible to the user and have a duration of `0`. Yospace's recommendation for us // is to filter out ads with duration = 0. - return this.session - .getAdBreaksByType(BreakType.LINEAR) - .map((adBreak: AdBreak) => this.mapAdBreak(adBreak)) - .filter((adBreak: YospaceAdBreak) => adBreak.duration > 0); + return ( + this.session + .getAdBreaksByType(BreakType.LINEAR) + .map((adBreak: AdBreak) => this.mapAdBreak(adBreak)) + // filter out ad breaks deactivated by ad immunity + .filter((adBreak: YospaceAdBreak) => adBreak.active && adBreak.duration > 0) + ); }, schedule: (adConfig: AdConfig) => { @@ -1126,7 +1214,49 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { } }; + private performBreakSkip(seekTarget: number) { + this.player.pause(); + this.unpauseAfterSeek = true; + + this.player.seek(seekTarget); + } + private onTimeChanged = (event: TimeChangedEvent) => { + // the offset is an attempt to prevent the first few frames of an ad + // playing before the seek past the break has time to propagate + const adBreakCheckOffset = + typeof this.adImmunityConfig.adBreakCheckOffset === 'number' + ? this.adImmunityConfig.adBreakCheckOffset + : this.defaultAdBreakCheckOffset; + const upcomingAdBreak: AdBreak | null = this.session.getAdBreakForPlayhead( + toMilliseconds(event.time) + toMilliseconds(adBreakCheckOffset) + ); + + // exclude postrolls and unknown break positions from ad immunity to prevent seek loops at end of video + if (upcomingAdBreak?.getPosition() !== 'postroll' && upcomingAdBreak?.getPosition() !== 'unknown') { + // Seek past previously deactivated ad breaks + if (upcomingAdBreak && !upcomingAdBreak.isActive()) { + Logger.log('[BitmovinYospacePlayer] - Ad Immunity pausing and seeking past deactivated ad break'); + + this.performBreakSkip(toSeconds(upcomingAdBreak.getStart() + upcomingAdBreak.getDuration())); + + // do not propagate time to the rest of the app, we want to seek past it + return; + } + + // seek past and deactivate ad breaks entered during ad immunity + if (upcomingAdBreak && this.adImmune) { + upcomingAdBreak.setInactive(); + + Logger.log('[BitmovinYospacePlayer] - Ad Immunity pausing, seeking past and deactivating ad break'); + + this.performBreakSkip(toSeconds(upcomingAdBreak.getStart() + upcomingAdBreak.getDuration())); + + // do not propagate time to the rest of the app, we want to seek past it + return; + } + } + // There is an outstanding bug on Safari mobile where upon exiting an ad break, // our TimeChanged event "rewinds" ~12 ms. This is a temporary fix. // If we report this "rewind" to Yospace, it results in duplicate ad events. @@ -1136,9 +1266,7 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { if (timeDifference >= 0 || timeDifference < -0.25) { this.session.onPlayheadUpdate(toMilliseconds(event.time)); } else { - Logger.warn( - 'Encountered a small negative TimeChanged update, not reporting to Yospace. Difference was: ' + timeDifference - ); + Logger.warn('Encountered a small negative TimeChanged update, not reporting to Yospace. Difference was: ' + timeDifference); } // fire magic time-changed event @@ -1170,6 +1298,12 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { private onSeeked = (event: SeekEvent) => { Logger.log('[BitmovinYospacePlayer] - sending YospaceAdManagement.PlayerEvent.SEEK (from Seeked player event)'); + + if (this.unpauseAfterSeek) { + this.unpauseAfterSeek = false; + this.play(); + } + this.session.onPlayerEvent(YsPlayerEvent.SEEK, toMilliseconds(this.player.getCurrentTime())); if (!this.suppressedEventsController.isSuppressed(this.player.exports.PlayerEvent.Seeked)) { @@ -1210,12 +1344,15 @@ export class InternalBitmovinYospacePlayer implements BitmovinYospacePlayerAPI { let yospaceMetadataObject: TimedMetadata; if (type === 'ID3') { yospaceMetadataObject = this.parseId3Tags(event); - Logger.log('[BitmovinYospacePlayer] - sending YSPlayerEvents.METADATA ' + stringify(yospaceMetadataObject)); - this.session.onTimedMetadata(yospaceMetadataObject); } else if (type === 'EMSG') { yospaceMetadataObject = this.mapEmsgToId3Tags(event); + } + + if (yospaceMetadataObject) { Logger.log('[BitmovinYospacePlayer] - sending YSPlayerEvents.METADATA ' + stringify(yospaceMetadataObject)); this.session.onTimedMetadata(yospaceMetadataObject); + } else { + Logger.log('[BitmovinYospacePlayer] - found metadata but does not appear to be yospace related: ' + stringify(event)); } }; diff --git a/src/ts/YospaceListenerAdapter.ts b/src/ts/YospaceListenerAdapter.ts index 82fc6b0..f015fff 100644 --- a/src/ts/YospaceListenerAdapter.ts +++ b/src/ts/YospaceListenerAdapter.ts @@ -98,6 +98,10 @@ export class YospaceAdListenerAdapter { } onTrackingEvent(type: BYSTrackingEventType) { + // TO DO: For pre-rolls, not all ad details from Yospace might be available in the + // AdBreakStarted/AdStarted events. If those are needed, we might need to wait for + // the `onTrackingEvent` to fire. + Logger.log('[listener] AnalyticsFired', type); const event: BYSAnalyticsFiredEvent = { type: BYSListenerEvent.ANALYTICS_FIRED, @@ -107,6 +111,18 @@ export class YospaceAdListenerAdapter { this.emitEvent(event); } + onAdvertBreakEarlyReturn() { + Logger.warn('[BYP][listener] onAdvertBreakEarlyReturn not implemented'); + } + + onSessionError() { + Logger.warn('[BYP][listener] onSessionError not implemented'); + } + + onTrackingError() { + Logger.warn('[BYP][listener] onTrackingError not implemented'); + } + private emitEvent(event: BYSListenerEventBase) { if (this.listeners[event.type]) { for (const callback of this.listeners[event.type]) {