From 202fdfde811cb9a2552f6c00fbd3f28bde1659d9 Mon Sep 17 00:00:00 2001 From: Kirill Karpov <85180621+karpov-kir@users.noreply.github.com> Date: Mon, 8 Jul 2024 12:16:51 +0400 Subject: [PATCH] feat: SSAI tracking (#112) --- CHANGELOG.md | 5 + example/index.html | 35 +- package-lock.json | 496 ++++------ package.json | 1 + spec/helper/MockHelper.ts | 16 +- spec/tests/AdHelper.spec.ts | 18 +- spec/tests/ConvivaAnalytics.spec.ts | 6 +- spec/tests/ConvivaAnalyticsSsai.spec.ts | 281 ++++++ spec/tests/ConvivaAnalyticsTracker.spec.ts | 39 + spec/tests/PlayerEvents.spec.ts | 2 + src/ts/ConvivaAnalytics.ts | 1028 +++----------------- src/ts/ConvivaAnalyticsSsai.ts | 94 ++ src/ts/ConvivaAnalyticsTracker.ts | 888 +++++++++++++++++ src/ts/helper/AdHelper.ts | 128 ++- src/ts/helper/ObjectUtils.ts | 2 +- src/ts/helper/PlayerConfigHelper.ts | 62 ++ src/ts/helper/PlayerEventWrapper.ts | 38 + src/ts/helper/PlayerStateHelper.ts | 43 + src/ts/index.ts | 4 +- 19 files changed, 1936 insertions(+), 1250 deletions(-) create mode 100644 spec/tests/ConvivaAnalyticsSsai.spec.ts create mode 100644 spec/tests/ConvivaAnalyticsTracker.spec.ts create mode 100644 src/ts/ConvivaAnalyticsSsai.ts create mode 100644 src/ts/ConvivaAnalyticsTracker.ts create mode 100644 src/ts/helper/PlayerConfigHelper.ts create mode 100644 src/ts/helper/PlayerEventWrapper.ts create mode 100644 src/ts/helper/PlayerStateHelper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1048771..237e351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Server side ad tracking + - It is exposed via `ConvivaAnalytics.ssai` + - An example can be found in `examples/index.html` + ## 5.1.0 - 2024-06-13 ### Added - Basic client ad tracking using `Conviva.AdAnalytics` diff --git a/example/index.html b/example/index.html index 36471be..c1dae70 100644 --- a/example/index.html +++ b/example/index.html @@ -70,10 +70,43 @@ convivaAnalytics = new bitmovin.player.analytics.ConvivaAnalytics(player, '', { debugLoggingEnabled: true, - gatewayUrl: 'https://youraccount-test.testonly.conviva.com', // TOUCHSTONE_SERVICE_URL for testing + // TOUCHSTONE_SERVICE_URL for testing (DO NOT USE IN PRODUCTION) + gatewayUrl: 'https://youraccount-test.testonly.conviva.com', // This is inferred automatically, but can be overridden. // deviceMetadata: { ... } }); + + const simulateSsaiAd = () => { + // To avoid cyclic calls. + player.off(bitmovin.player.PlayerEvent.AdBreakFinished, simulateSsaiAd); + + const playSsaiAd = () => { + convivaAnalytics.ssai.reportAdBreakStarted(); + convivaAnalytics.ssai.reportAdStarted({ + id: 'dummy-web-server-side-ad', + title: 'Dummy web server side AD', + duration: 20, + adSystem: 'dummy-server-side-ad-system', + position: Conviva.Constants.AdPosition.PREROLL, + isSlate: false, + adStitcher: 'Dummy AD stitcher', + additionalMetadata: { + dummyKey: 'dummy-value' + } + }); + + // "Play" the server side ad for 20 seconds. + setTimeout(() => { + convivaAnalytics.ssai.reportAdFinished(); + convivaAnalytics.ssai.reportAdBreakFinished(); + }, 20_000); + } + + // Simulate server-side ad break with a little delay after the previous client side ad. + setTimeout(playSsaiAd, 2_000); + } + + player.on(bitmovin.player.PlayerEvent.AdBreakFinished, simulateSsaiAd) } function loadPlayer() { diff --git a/package-lock.json b/package-lock.json index 7d43260..a49fc8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "create-file-webpack": "^1.0.2", "husky": "^8.0.3", "jest": "^24.1.0", + "jest-mock-extended": "^3.0.7", "kacl": "^1.1.1", "lint-staged": "^13.2.2", "prettier": "^2.8.8", @@ -3475,34 +3476,30 @@ }, "node_modules/fsevents/node_modules/abbrev": { "version": "1.1.1", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/ansi-regex": { "version": "2.1.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/aproba": { "version": "1.2.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/are-we-there-yet": { "version": "1.1.5", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" @@ -3510,17 +3507,15 @@ }, "node_modules/fsevents/node_modules/balanced-match": { "version": "1.0.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/brace-expansion": { "version": "1.1.11", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3528,75 +3523,66 @@ }, "node_modules/fsevents/node_modules/chownr": { "version": "1.1.3", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/code-point-at": { "version": "1.1.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/concat-map": { "version": "0.0.1", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/console-control-strings": { "version": "1.1.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/core-util-is": { "version": "1.0.2", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/debug": { "version": "3.2.6", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.1" } }, "node_modules/fsevents/node_modules/deep-extend": { "version": "0.6.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=4.0.0" } }, "node_modules/fsevents/node_modules/delegates": { "version": "1.0.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/detect-libc": { "version": "1.0.3", - "dev": true, + "extraneous": true, "inBundle": true, "license": "Apache-2.0", - "optional": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -3606,27 +3592,24 @@ }, "node_modules/fsevents/node_modules/fs-minipass": { "version": "1.2.7", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "minipass": "^2.6.0" } }, "node_modules/fsevents/node_modules/fs.realpath": { "version": "1.0.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/gauge": { "version": "2.7.4", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -3640,10 +3623,9 @@ }, "node_modules/fsevents/node_modules/glob": { "version": "7.1.6", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -3661,17 +3643,15 @@ }, "node_modules/fsevents/node_modules/has-unicode": { "version": "2.0.1", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/iconv-lite": { "version": "0.4.24", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3681,20 +3661,18 @@ }, "node_modules/fsevents/node_modules/ignore-walk": { "version": "3.0.3", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "minimatch": "^3.0.4" } }, "node_modules/fsevents/node_modules/inflight": { "version": "1.0.6", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -3702,27 +3680,24 @@ }, "node_modules/fsevents/node_modules/inherits": { "version": "2.0.4", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/ini": { "version": "1.3.5", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "engines": { "node": "*" } }, "node_modules/fsevents/node_modules/is-fullwidth-code-point": { "version": "1.0.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -3732,17 +3707,15 @@ }, "node_modules/fsevents/node_modules/isarray": { "version": "1.0.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/minimatch": { "version": "3.0.4", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3752,17 +3725,15 @@ }, "node_modules/fsevents/node_modules/minimist": { "version": "0.0.8", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/minipass": { "version": "2.9.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -3770,20 +3741,18 @@ }, "node_modules/fsevents/node_modules/minizlib": { "version": "1.3.3", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "minipass": "^2.9.0" } }, "node_modules/fsevents/node_modules/mkdirp": { "version": "0.5.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "minimist": "0.0.8" }, @@ -3793,17 +3762,15 @@ }, "node_modules/fsevents/node_modules/ms": { "version": "2.1.2", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/needle": { "version": "2.4.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "debug": "^3.2.6", "iconv-lite": "^0.4.4", @@ -3818,10 +3785,9 @@ }, "node_modules/fsevents/node_modules/node-pre-gyp": { "version": "0.14.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", @@ -3840,10 +3806,9 @@ }, "node_modules/fsevents/node_modules/nopt": { "version": "4.0.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "abbrev": "1", "osenv": "^0.1.4" @@ -3854,27 +3819,24 @@ }, "node_modules/fsevents/node_modules/npm-bundled": { "version": "1.1.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "npm-normalize-package-bin": "^1.0.1" } }, "node_modules/fsevents/node_modules/npm-normalize-package-bin": { "version": "1.0.1", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/npm-packlist": { "version": "1.4.7", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "ignore-walk": "^3.0.1", "npm-bundled": "^1.0.1" @@ -3882,10 +3844,9 @@ }, "node_modules/fsevents/node_modules/npmlog": { "version": "4.1.2", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -3895,60 +3856,54 @@ }, "node_modules/fsevents/node_modules/number-is-nan": { "version": "1.0.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/object-assign": { "version": "4.1.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/once": { "version": "1.4.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } }, "node_modules/fsevents/node_modules/os-homedir": { "version": "1.0.2", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/os-tmpdir": { "version": "1.0.2", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/osenv": { "version": "0.1.5", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "os-homedir": "^1.0.0", "os-tmpdir": "^1.0.0" @@ -3956,27 +3911,24 @@ }, "node_modules/fsevents/node_modules/path-is-absolute": { "version": "1.0.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/process-nextick-args": { "version": "2.0.1", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/rc": { "version": "1.2.8", - "dev": true, + "extraneous": true, "inBundle": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -3989,17 +3941,15 @@ }, "node_modules/fsevents/node_modules/rc/node_modules/minimist": { "version": "1.2.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/readable-stream": { "version": "2.3.6", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4012,10 +3962,9 @@ }, "node_modules/fsevents/node_modules/rimraf": { "version": "2.7.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "glob": "^7.1.3" }, @@ -4025,65 +3974,57 @@ }, "node_modules/fsevents/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/safer-buffer": { "version": "2.1.2", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/sax": { "version": "1.2.4", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/semver": { "version": "5.7.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver" } }, "node_modules/fsevents/node_modules/set-blocking": { "version": "2.0.0", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/signal-exit": { "version": "3.0.2", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/string_decoder": { "version": "1.1.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/fsevents/node_modules/string-width": { "version": "1.0.2", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4095,10 +4036,9 @@ }, "node_modules/fsevents/node_modules/strip-ansi": { "version": "3.0.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -4108,20 +4048,18 @@ }, "node_modules/fsevents/node_modules/strip-json-comments": { "version": "2.0.1", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } }, "node_modules/fsevents/node_modules/tar": { "version": "4.4.13", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "chownr": "^1.1.1", "fs-minipass": "^1.2.5", @@ -4137,34 +4075,30 @@ }, "node_modules/fsevents/node_modules/util-deprecate": { "version": "1.0.2", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fsevents/node_modules/wide-align": { "version": "1.1.3", - "dev": true, + "extraneous": true, "inBundle": true, "license": "ISC", - "optional": true, "dependencies": { "string-width": "^1.0.2 || 2" } }, "node_modules/fsevents/node_modules/wrappy": { "version": "1.0.2", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/fsevents/node_modules/yallist": { "version": "3.1.1", - "dev": true, + "extraneous": true, "inBundle": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/function-bind": { "version": "1.1.1", @@ -5674,6 +5608,19 @@ "node": ">= 6" } }, + "node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/jest-regex-util": { "version": "24.0.0", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.0.0.tgz", @@ -10027,6 +9974,20 @@ "node": ">=0.10.0" } }, + "node_modules/ts-essentials": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.1.tgz", + "integrity": "sha512-HPH+H2bkkO8FkMDau+hFvv7KYozzned9Zr1Urn7rRPXMF4mZmCKOq+u4AI1AAW+2bofIOXTuSdKo9drQuni2dQ==", + "dev": true, + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-jest": { "version": "23.10.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-23.10.5.tgz", @@ -14179,26 +14140,22 @@ "abbrev": { "version": "1.1.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "aproba": { "version": "1.2.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "are-we-there-yet": { "version": "1.1.5", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" @@ -14207,14 +14164,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -14223,38 +14178,32 @@ "chownr": { "version": "1.1.3", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "core-util-is": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "debug": { "version": "3.2.6", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "ms": "^2.1.1" } @@ -14262,26 +14211,22 @@ "deep-extend": { "version": "0.6.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "delegates": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "detect-libc": { "version": "1.0.3", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "fs-minipass": { "version": "1.2.7", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "minipass": "^2.6.0" } @@ -14289,14 +14234,12 @@ "fs.realpath": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "gauge": { "version": "2.7.4", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -14311,8 +14254,7 @@ "glob": { "version": "7.1.6", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -14325,14 +14267,12 @@ "has-unicode": { "version": "2.0.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "iconv-lite": { "version": "0.4.24", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -14340,8 +14280,7 @@ "ignore-walk": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "minimatch": "^3.0.4" } @@ -14349,8 +14288,7 @@ "inflight": { "version": "1.0.6", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -14359,20 +14297,17 @@ "inherits": { "version": "2.0.4", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "ini": { "version": "1.3.5", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "number-is-nan": "^1.0.0" } @@ -14380,14 +14315,12 @@ "isarray": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "minimatch": { "version": "3.0.4", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "brace-expansion": "^1.1.7" } @@ -14395,14 +14328,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "minipass": { "version": "2.9.0", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -14411,8 +14342,7 @@ "minizlib": { "version": "1.3.3", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "minipass": "^2.9.0" } @@ -14420,8 +14350,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "minimist": "0.0.8" } @@ -14429,14 +14358,12 @@ "ms": { "version": "2.1.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "needle": { "version": "2.4.0", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "debug": "^3.2.6", "iconv-lite": "^0.4.4", @@ -14446,8 +14373,7 @@ "node-pre-gyp": { "version": "0.14.0", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "detect-libc": "^1.0.2", "mkdirp": "^0.5.1", @@ -14464,8 +14390,7 @@ "nopt": { "version": "4.0.1", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "abbrev": "1", "osenv": "^0.1.4" @@ -14474,8 +14399,7 @@ "npm-bundled": { "version": "1.1.1", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "npm-normalize-package-bin": "^1.0.1" } @@ -14483,14 +14407,12 @@ "npm-normalize-package-bin": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "npm-packlist": { "version": "1.4.7", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "ignore-walk": "^3.0.1", "npm-bundled": "^1.0.1" @@ -14499,8 +14421,7 @@ "npmlog": { "version": "4.1.2", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -14511,20 +14432,17 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "object-assign": { "version": "4.1.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "once": { "version": "1.4.0", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "wrappy": "1" } @@ -14532,20 +14450,17 @@ "os-homedir": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "os-tmpdir": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "osenv": { "version": "0.1.5", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "os-homedir": "^1.0.0", "os-tmpdir": "^1.0.0" @@ -14554,20 +14469,17 @@ "path-is-absolute": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "process-nextick-args": { "version": "2.0.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "rc": { "version": "1.2.8", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -14578,16 +14490,14 @@ "minimist": { "version": "1.2.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true } } }, "readable-stream": { "version": "2.3.6", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -14601,8 +14511,7 @@ "rimraf": { "version": "2.7.1", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "glob": "^7.1.3" } @@ -14610,44 +14519,37 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "safer-buffer": { "version": "2.1.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "sax": { "version": "1.2.4", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "semver": { "version": "5.7.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "set-blocking": { "version": "2.0.0", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "signal-exit": { "version": "3.0.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "string_decoder": { "version": "1.1.1", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "safe-buffer": "~5.1.0" } @@ -14655,8 +14557,7 @@ "string-width": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -14666,8 +14567,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "ansi-regex": "^2.0.0" } @@ -14675,14 +14575,12 @@ "strip-json-comments": { "version": "2.0.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "tar": { "version": "4.4.13", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "chownr": "^1.1.1", "fs-minipass": "^1.2.5", @@ -14696,14 +14594,12 @@ "util-deprecate": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "wide-align": { "version": "1.1.3", "bundled": true, - "dev": true, - "optional": true, + "extraneous": true, "requires": { "string-width": "^1.0.2 || 2" } @@ -14711,14 +14607,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "extraneous": true }, "yallist": { "version": "3.1.1", "bundled": true, - "dev": true, - "optional": true + "extraneous": true } } }, @@ -15955,6 +15849,15 @@ "integrity": "sha512-sQp0Hu5fcf5NZEh1U9eIW2qD0BwJZjb63Yqd98PQJFvf/zzUTBoUAwv/Dc/HFeNHIw1f3hl/48vNn+j3STaI7A==", "dev": true }, + "jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "requires": { + "ts-essentials": "^10.0.0" + } + }, "jest-regex-util": { "version": "24.0.0", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-24.0.0.tgz", @@ -19392,6 +19295,13 @@ "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", "dev": true }, + "ts-essentials": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.1.tgz", + "integrity": "sha512-HPH+H2bkkO8FkMDau+hFvv7KYozzned9Zr1Urn7rRPXMF4mZmCKOq+u4AI1AAW+2bofIOXTuSdKo9drQuni2dQ==", + "dev": true, + "requires": {} + }, "ts-jest": { "version": "23.10.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-23.10.5.tgz", diff --git a/package.json b/package.json index 6e3f060..5c75966 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "create-file-webpack": "^1.0.2", "husky": "^8.0.3", "jest": "^24.1.0", + "jest-mock-extended": "^3.0.7", "kacl": "^1.1.1", "lint-staged": "^13.2.2", "prettier": "^2.8.8", diff --git a/spec/helper/MockHelper.ts b/spec/helper/MockHelper.ts index 561c08e..3dda58b 100644 --- a/spec/helper/MockHelper.ts +++ b/spec/helper/MockHelper.ts @@ -13,6 +13,7 @@ import { CastStartedEvent, AudioChangedEvent, SubtitleEvent, + VideoQuality, } from 'bitmovin-player'; import { ArrayUtils } from 'bitmovin-player-ui/dist/js/framework/arrayutils'; import * as Conviva from '@convivainc/conviva-js-coresdk'; @@ -208,8 +209,9 @@ export namespace MockHelper { getConfig: jest.fn(() => { return {}; }), - isPlaying: jest.fn(), - isPaused: jest.fn(), + isPlaying: jest.fn().mockReturnValue(true), + isPaused: jest.fn().mockReturnValue(false), + isStalled: jest.fn().mockReturnValue(false), isCasting: jest.fn(), getPlayerType: jest.fn(), getStreamType: jest.fn(() => 'hls'), @@ -226,6 +228,16 @@ export namespace MockHelper { }, on: (eventType: PlayerEvent, callback: PlayerEventCallback) => playerEventHelper.on(eventType, callback), off: (eventType: PlayerEvent, callback: PlayerEventCallback) => playerEventHelper.off(eventType, callback), + getPlaybackVideoData: jest.fn(() => { + const data: VideoQuality = { + bitrate: 1024, + id: 'test-video-quality', + width: 100, + height: 100, + frameRate: 60 + } + return data; + }) }; }); diff --git a/spec/tests/AdHelper.spec.ts b/spec/tests/AdHelper.spec.ts index 513be91..6933ccf 100644 --- a/spec/tests/AdHelper.spec.ts +++ b/spec/tests/AdHelper.spec.ts @@ -12,7 +12,7 @@ describe(AdHelper, () => { scheduleTime: 0 } as AdBreak; - expect(AdHelper.mapAdPosition(adBreak, player)).toEqual(Conviva.Constants.AdPosition.PREROLL) + expect(AdHelper.mapCsaiAdPosition(adBreak, player)).toEqual(Conviva.Constants.AdPosition.PREROLL) }) it('should map ad position to postroll', () => { @@ -23,7 +23,7 @@ describe(AdHelper, () => { scheduleTime: 100 } as AdBreak; - expect(AdHelper.mapAdPosition(adBreak, player)).toEqual(Conviva.Constants.AdPosition.POSTROLL) + expect(AdHelper.mapCsaiAdPosition(adBreak, player)).toEqual(Conviva.Constants.AdPosition.POSTROLL) }) it('should map ad position to midroll', () => { @@ -34,13 +34,13 @@ describe(AdHelper, () => { scheduleTime: 50 } as AdBreak; - expect(AdHelper.mapAdPosition(adBreak, player)).toEqual(Conviva.Constants.AdPosition.MIDROLL) + expect(AdHelper.mapCsaiAdPosition(adBreak, player)).toEqual(Conviva.Constants.AdPosition.MIDROLL) }) }) describe('formatAdErrorEvent', () => { - it('should format minimal error message', () => { - expect(AdHelper.formatAdErrorEvent({ + it('should format error message', () => { + expect(AdHelper.formatCsaiAdError({ code: ErrorCode.NETWORK_ERROR, name: 'Test error', troubleShootLink: 'https://test.com', @@ -50,7 +50,7 @@ describe(AdHelper, () => { }) it('should format full error message', () => { - expect(AdHelper.formatAdErrorEvent({ + expect(AdHelper.formatCsaiAdError({ code: ErrorCode.NETWORK_ERROR, name: 'Test error', troubleShootLink: 'https://test.com', @@ -79,7 +79,7 @@ describe(AdHelper, () => { }, } as AdEvent; - expect(AdHelper.extractConvivaAdInfo(player, adBreakEvent, adEvent)).toEqual({ + expect(AdHelper.extractCsaiConvivaAdInfo(player, adBreakEvent, adEvent)).toEqual({ "c3.ad.creativeId": "NA", "c3.ad.firstAdId": "123", "c3.ad.firstAdSystem": "NA", @@ -89,6 +89,8 @@ describe(AdHelper, () => { "c3.ad.position": "Pre-roll", "c3.ad.system": "NA", "c3.ad.technology": "Client Side", + [Conviva.Constants.ASSET_NAME]: 'NA', + [Conviva.Constants.STREAM_URL]: 'NA', }) }) @@ -117,7 +119,7 @@ describe(AdHelper, () => { } as Ad | LinearAd, } as AdEvent; - expect(AdHelper.extractConvivaAdInfo(player, adBreakEvent, adEvent)).toEqual({ + expect(AdHelper.extractCsaiConvivaAdInfo(player, adBreakEvent, adEvent)).toEqual({ [Conviva.Constants.ASSET_NAME]: "Test title", [Conviva.Constants.STREAM_URL]: 'https://test.com', [Conviva.Constants.DURATION]: 100, diff --git a/spec/tests/ConvivaAnalytics.spec.ts b/spec/tests/ConvivaAnalytics.spec.ts index 31a268f..8c759a8 100644 --- a/spec/tests/ConvivaAnalytics.spec.ts +++ b/spec/tests/ConvivaAnalytics.spec.ts @@ -481,15 +481,17 @@ describe(ConvivaAnalytics, () => { playerEventHelper.fireAdStartedEvent(); expect(MockHelper.latestAdAnalytics.reportAdStarted).toHaveBeenCalledWith({ + [Conviva.Constants.ASSET_NAME]: 'NA', + [Conviva.Constants.STREAM_URL]: 'NA', "c3.ad.creativeId": "NA", "c3.ad.firstAdId": 'Ad-ID', "c3.ad.firstAdSystem": "NA", "c3.ad.firstCreativeId": "NA", "c3.ad.id": 'Ad-ID', "c3.ad.mediaFileApiFramework": "NA", - "c3.ad.position": "Pre-roll", + "c3.ad.position": Conviva.Constants.AdPosition.PREROLL, "c3.ad.system": "NA", - "c3.ad.technology": "Client Side" + "c3.ad.technology": Conviva.Constants.AdType.CLIENT_SIDE }); }); diff --git a/spec/tests/ConvivaAnalyticsSsai.spec.ts b/spec/tests/ConvivaAnalyticsSsai.spec.ts new file mode 100644 index 0000000..e2a6715 --- /dev/null +++ b/spec/tests/ConvivaAnalyticsSsai.spec.ts @@ -0,0 +1,281 @@ +import { ConvivaAnalyticsTracker, INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG, STREAM_TYPE_CONTENT_METADATA_CUSTOM_TAG } from "../../src/ts/ConvivaAnalyticsTracker"; +import { ConvivaAnalyticsSsai } from "../../src/ts/ConvivaAnalyticsSsai"; +import { mock } from 'jest-mock-extended'; +import * as Conviva from '@convivainc/conviva-js-coresdk'; +import { ContentMetadataBuilder } from "../../src/ts/ContentMetadataBuilder"; + +describe(ConvivaAnalyticsSsai, () => { + it('should report isAdBreakActive as false initially', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + expect(ssai.isAdBreakActive).toBe(false); + }); + + it('should report isAdBreakActive as true after reportAdBreakStarted', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + + expect(ssai.isAdBreakActive).toBe(true); + }); + + it('should report isAdBreakActive as false after reset', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reset(); + + expect(ssai.isAdBreakActive).toBe(false); + }); + + it('should report ad break started', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + + expect(convivaAnalyticsTrackerMock.trackAdBreakStarted).toHaveBeenCalledWith(Conviva.Constants.AdType.SERVER_SIDE); + }) + + it('should not report ad break started is server side ad is active already', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdBreakStarted(); + + expect(convivaAnalyticsTrackerMock.trackAdBreakStarted).toHaveBeenCalledTimes(1); + }) + + it('should not report ad break started is client side ad is active already', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: true, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + + expect(convivaAnalyticsTrackerMock.trackAdBreakStarted).not.toHaveBeenCalled(); + }) + + it('should report ad started', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + getContentMetadata: () => ({}) + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdStarted({ + id: 'adId', + title: 'adTitle', + adSystem: 'adSystem', + adStitcher: 'adStitcher', + isSlate: false, + additionalMetadata: { + customKey: 'customValuie' + } + }); + + expect(convivaAnalyticsTrackerMock.trackAdStarted).toHaveBeenCalledWith({ + 'c3.ad.id': 'adId', + 'c3.ad.technology': Conviva.Constants.AdType.SERVER_SIDE, + 'c3.ad.position': 'NA', + 'c3.ad.system': 'adSystem', + [Conviva.Constants.ASSET_NAME]: 'adTitle', + 'c3.ad.adStitcher': 'adStitcher', + 'c3.ad.isSlate': 'false', + 'customKey': 'customValuie', + }, Conviva.Constants.AdType.SERVER_SIDE); + }) + + it('should report ad started with specific data picked from current content metadata', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + getContentMetadata: () => ({ + [INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG]: '1.0.0', + [STREAM_TYPE_CONTENT_METADATA_CUSTOM_TAG]: 'Stream type from current metadata', + [Conviva.Constants.ASSET_NAME]: 'Asset name from current metadata', + [Conviva.Constants.IS_LIVE]: Conviva.Constants.StreamType.LIVE, + [Conviva.Constants.DEFAULT_RESOURCE]: 'Default resource from current metadata', + [Conviva.Constants.ENCODED_FRAMERATE]: null, + [Conviva.Constants.VIEWER_ID]: 'Viewer id from current metadata', + [Conviva.Constants.PLAYER_NAME]: 'Player name from current metadata', + // Should not be included in the ad metadata + customKey: 'Custom value from current metadata' + }) + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdStarted({ + id: 'adId', + }); + + expect(convivaAnalyticsTrackerMock.trackAdStarted).toHaveBeenCalledWith({ + 'c3.ad.id': 'adId', + 'c3.ad.technology': Conviva.Constants.AdType.SERVER_SIDE, + [INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG]: '1.0.0', + [STREAM_TYPE_CONTENT_METADATA_CUSTOM_TAG]: 'Stream type from current metadata', + [Conviva.Constants.ASSET_NAME]: 'Asset name from current metadata', + [Conviva.Constants.IS_LIVE]: Conviva.Constants.StreamType.LIVE, + [Conviva.Constants.DEFAULT_RESOURCE]: 'Default resource from current metadata', + [Conviva.Constants.ENCODED_FRAMERATE]: null, + [Conviva.Constants.VIEWER_ID]: 'Viewer id from current metadata', + [Conviva.Constants.PLAYER_NAME]: 'Player name from current metadata', + 'c3.ad.isSlate': 'NA', + 'c3.ad.position': 'NA', + 'c3.ad.system': 'NA', + 'c3.ad.adStitcher': 'NA', + }, Conviva.Constants.AdType.SERVER_SIDE); + }) + + it('should prioritize user provided data', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + getContentMetadata: () => ({ + [Conviva.Constants.ASSET_NAME]: 'Asset name from current metadata', + }) + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdStarted({ + id: 'adId', + title: 'User provided asset name', + }); + + expect(convivaAnalyticsTrackerMock.trackAdStarted).toHaveBeenCalledWith(expect.objectContaining({ + [Conviva.Constants.ASSET_NAME]: 'User provided asset name', + }), Conviva.Constants.AdType.SERVER_SIDE); + }) + + it('should not pass custom key from content metadata to ad info', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + getContentMetadata: () => ({ + // Should not be included in the ad metadata + customKey: 'Custom value from current metadata' + }) + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdStarted({ + id: 'adId', + title: 'Test title', + }); + + expect(convivaAnalyticsTrackerMock.trackAdStarted).toHaveBeenCalledWith(expect.objectContaining({ + [Conviva.Constants.ASSET_NAME]: 'Test title', + }), Conviva.Constants.AdType.SERVER_SIDE); + }) + + it('should not report ad started if ad break is not active', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdStarted({ + id: 'adId', + }); + + expect(convivaAnalyticsTrackerMock.trackAdStarted).not.toHaveBeenCalled(); + }) + + it('should report ad finished', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdFinished(); + + expect(convivaAnalyticsTrackerMock.trackAdFinished).toHaveBeenCalled(); + }) + + it('should not report ad finished if ad break is not active', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdFinished(); + + expect(convivaAnalyticsTrackerMock.trackAdFinished).not.toHaveBeenCalled(); + }) + + it('should report ad skipped', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdSkipped(); + + expect(convivaAnalyticsTrackerMock.trackAdSkipped).toHaveBeenCalled(); + }) + + it('should not report ad skipped if ad break is not active', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdSkipped(); + + expect(convivaAnalyticsTrackerMock.trackAdSkipped).not.toHaveBeenCalled(); + }) + + it('should report ad break finished', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdBreakFinished(); + + expect(convivaAnalyticsTrackerMock.trackAdBreakFinished).toHaveBeenCalled(); + }) + + it('should not report ad break finished if ad break is not active', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakFinished(); + + expect(convivaAnalyticsTrackerMock.trackAdBreakFinished).not.toHaveBeenCalled(); + }) + + it('should allow reporting ad break started after the previous ad break has finished', () => { + const convivaAnalyticsTrackerMock = mock({ + isAdBreakActive: false, + }); + const ssai = new ConvivaAnalyticsSsai(convivaAnalyticsTrackerMock); + + ssai.reportAdBreakStarted(); + ssai.reportAdBreakFinished(); + ssai.reportAdBreakStarted(); + + expect(convivaAnalyticsTrackerMock.trackAdBreakStarted).toHaveBeenCalledTimes(2); + }) +}) diff --git a/spec/tests/ConvivaAnalyticsTracker.spec.ts b/spec/tests/ConvivaAnalyticsTracker.spec.ts new file mode 100644 index 0000000..56189c9 --- /dev/null +++ b/spec/tests/ConvivaAnalyticsTracker.spec.ts @@ -0,0 +1,39 @@ +import { ConvivaAnalyticsTracker } from "../../src/ts/ConvivaAnalyticsTracker"; +import { MockHelper } from "../helper/MockHelper"; +import * as Conviva from '@convivainc/conviva-js-coresdk'; + +jest.mock('@convivainc/conviva-js-coresdk', () => { + const { MockHelper } = jest.requireActual('../helper/MockHelper'); + return MockHelper.createConvivaMock(); +}); +jest.mock('../../src/ts/Html5Logging'); + +describe(ConvivaAnalyticsTracker, () => { + it('should report ad resolution and framerate for server side ad', () => { + const {playerMock} = MockHelper.createPlayerMock(); + const convivaAnalyticsTracker = new ConvivaAnalyticsTracker(playerMock, 'test-key'); + + jest.spyOn(playerMock, 'getSource').mockImplementation(() => ({})); + + convivaAnalyticsTracker.initializeSession(); + + convivaAnalyticsTracker.trackAdStarted({}, Conviva.Constants.AdType.SERVER_SIDE); + + expect(MockHelper.latestAdAnalytics.reportAdMetric).toHaveBeenCalledWith(Conviva.Constants.Playback.RESOLUTION, '100x100'); + expect(MockHelper.latestAdAnalytics.reportAdMetric).toHaveBeenCalledWith(Conviva.Constants.Playback.RENDERED_FRAMERATE, 60); + }) + + it('should not report ad resolution and framerate for client side ad', () => { + const {playerMock} = MockHelper.createPlayerMock(); + const convivaAnalyticsTracker = new ConvivaAnalyticsTracker(playerMock, 'test-key'); + + jest.spyOn(playerMock, 'getSource').mockImplementation(() => ({})); + + convivaAnalyticsTracker.initializeSession(); + + convivaAnalyticsTracker.trackAdStarted({}, Conviva.Constants.AdType.CLIENT_SIDE); + + expect(MockHelper.latestAdAnalytics.reportAdMetric).not.toHaveBeenCalledWith(Conviva.Constants.Playback.RESOLUTION, expect.anything()); + expect(MockHelper.latestAdAnalytics.reportAdMetric).not.toHaveBeenCalledWith(Conviva.Constants.Playback.RENDERED_FRAMERATE, expect.anything()); + }) +}) diff --git a/spec/tests/PlayerEvents.spec.ts b/spec/tests/PlayerEvents.spec.ts index 394a672..91265be 100644 --- a/spec/tests/PlayerEvents.spec.ts +++ b/spec/tests/PlayerEvents.spec.ts @@ -196,6 +196,7 @@ describe('player event tests', () => { playerEventHelper.fireSeekEvent(50.145); expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).toHaveBeenCalledWith( Conviva.Constants.Playback.SEEK_STARTED, + expect.any(Number) ); }); @@ -203,6 +204,7 @@ describe('player event tests', () => { playerEventHelper.fireTimeShiftEvent(); expect(MockHelper.latestVideoAnalytics.reportPlaybackMetric).toHaveBeenCalledWith( Conviva.Constants.Playback.SEEK_STARTED, + -1 ); }); }); diff --git a/src/ts/ConvivaAnalytics.ts b/src/ts/ConvivaAnalytics.ts index d64645d..54834a7 100644 --- a/src/ts/ConvivaAnalytics.ts +++ b/src/ts/ConvivaAnalytics.ts @@ -3,136 +3,30 @@ import type { AdBreakEvent, AdEvent, AudioChangedEvent, - AudioTrack, ErrorEvent, PlaybackEvent, PlayerAPI, PlayerEvent, PlayerEventBase, SeekEvent, - SourceConfig, TimeShiftEvent, VideoQualityChangedEvent, SubtitleEvent, - SubtitleTrack, - TimeMode, - AdData, - VastAdData, - Ad, - LinearAd, } from 'bitmovin-player'; -import { Html5Http } from './Html5Http'; -import { Html5Logging } from './Html5Logging'; -import { Html5Storage } from './Html5Storage'; -import { Html5Time } from './Html5Time'; -import { Html5Timer } from './Html5Timer'; -import { Timeout } from 'bitmovin-player-ui/dist/js/framework/timeout'; -import { ContentMetadataBuilder, Metadata } from './ContentMetadataBuilder'; +import { Metadata } from './ContentMetadataBuilder'; import { ObjectUtils } from './helper/ObjectUtils'; -import { BrowserUtils } from './helper/BrowserUtils'; -import { ArrayUtils } from 'bitmovin-player-ui/dist/js/framework/arrayutils'; +import { ConvivaAnalyticsConfiguration, ConvivaAnalyticsTracker, EventAttributes } from './ConvivaAnalyticsTracker'; +import { ConvivaAnalyticsSsai } from './ConvivaAnalyticsSsai'; +import { PlayerEventWrapper } from './helper/PlayerEventWrapper'; import { AdHelper } from './helper/AdHelper'; -type Player = PlayerAPI; - -export interface ConvivaAnalyticsConfiguration { - /** - * Enables debug logging when set to true (default: false). - */ - debugLoggingEnabled?: boolean; - /** - * The TOUCHSTONE_SERVICE_URL for testing with Touchstone. Only to be used for development, must not be set in - * production or automated testing. - */ - gatewayUrl?: string; - - /** - * Option to set the Conviva Device Category, which is used to assist with - * user agent string parsing by the Conviva SDK. (default: WEB) - * @deprecated Use `deviceMetadata.category` field - */ - deviceCategory?: Conviva.valueof; - - /** - * Option to override the Conviva Device Metadata. - * (Default: Auto extract all options from User Agent string) - */ - deviceMetadata?: { - /** - * Option to set the Conviva Device Category, which is used to assist with - * user agent string parsing by the Conviva SDK. - * (default: The same specified in config.deviceCategory) - */ - category?: Conviva.valueof; - - /** - * Option to override the Conviva Device Brand. - * (Default: Auto extract from User Agent string) - */ - brand?: string; - - /** - * Option to override the Conviva Device Manufacturer. - * (Default: Auto extract from User Agent string) - */ - manufacturer?: string; - - /** - * Option to override the Conviva Device Model. - * (Default: Auto extract from User Agent string) - */ - model?: string; - - /** - * Option to override the Conviva Device Type - * (Default: Auto extract from User Agent string) - */ - type?: Conviva.valueof; - - /** - * Option to override the Conviva Device Version. - * (Default: Auto extract from User Agent string) - */ - version?: string; - - /** - * Option to override the Conviva Operating System Name - * (Default: Auto extract from User Agent string) - */ - osName?: string; - - /** - * Option to override the Conviva Operating System Version - * (Default: Auto extract from User Agent string) - */ - osVersion?: string; - }; -} - -export interface EventAttributes { - [key: string]: string; -} - export class ConvivaAnalytics { - private static readonly VERSION: string = '{{VERSION}}'; - - private static STALL_TRACKING_DELAY_MS = 100; - private readonly player: Player; - private events: typeof PlayerEvent; + private readonly events: typeof PlayerEvent; private readonly handlers: PlayerEventWrapper; - private config: ConvivaAnalyticsConfiguration; - private readonly contentMetadataBuilder: ContentMetadataBuilder; - - private readonly logger: Conviva.LoggingInterface; - private sessionKey: number; - private convivaVideoAnalytics: Conviva.VideoAnalytics; - private convivaAdAnalytics: Conviva.AdAnalytics; + private readonly convivaAnalyticsTracker: ConvivaAnalyticsTracker; + private readonly player: PlayerAPI; - /** - * Tracks the ad break status and is true between ON_AD_STARTED and ON_AD_FINISHED/SKIPPED/ERROR. - * This flag is required because player.isAd() is unreliable and not always true between the events. - */ - private isAdBreak: boolean; + private readonly debugLoggingEnabled: boolean; /** * Tracks the last ad break event to get the ad position and other ad break related information @@ -140,93 +34,33 @@ export class ConvivaAnalytics { */ private lastAdBreakEvent: AdBreakEvent; - // Since there are no stall events during play / playing; seek / seeked; timeShift / timeShifted we need - // to track stalling state between those events. To prevent tracking eg. when seeking in buffer we delay it. - private stallTrackingTimeout: Timeout = new Timeout(ConvivaAnalytics.STALL_TRACKING_DELAY_MS, () => { - if (this.isAdBreak) { - this.debugLog('[ ConvivaAnalytics ] report buffering ad playback state'); - this.convivaAdAnalytics.reportAdMetric( - Conviva.Constants.Playback.PLAYER_STATE, - Conviva.Constants.PlayerState.BUFFERING, - ); - } else { - this.debugLog('[ ConvivaAnalytics ] report buffering playback state'); - this.convivaVideoAnalytics.reportPlaybackMetric( - Conviva.Constants.Playback.PLAYER_STATE, - Conviva.Constants.PlayerState.BUFFERING, - ); - } - - }); - - /** - * Boolean to track whether a session was ended by an upstream caller instead of within internal session management. - * If this is true, we should avoid initializing a new session internally if a session is not active - */ - private sessionEndedExternally = false; - - constructor(player: Player, customerKey: string, config: ConvivaAnalyticsConfiguration = {}) { - if (typeof Conviva === 'undefined') { - console.error( - `Conviva script missing, cannot init ConvivaAnalytics. Please load the Conviva script (conviva-core-sdk.min.js) before Bitmovin's ConvivaAnalytics integration.`, - ); - return; // Cancel initialization - } + private convivaSsaiAnalytics: ConvivaAnalyticsSsai; - if (player.getSource()) { - console.error('Bitmovin Conviva integration must be instantiated before calling player.load()'); - return; // Cancel initialization - } + public readonly ssai: Omit; + constructor(player: PlayerAPI, customerKey: string, config: ConvivaAnalyticsConfiguration = {}) { + this.convivaAnalyticsTracker = new ConvivaAnalyticsTracker(player, customerKey, config); + this.debugLoggingEnabled = config.debugLoggingEnabled || false; this.player = player; - // TODO: Use alternative to deprecated player.exports this.events = player.exports.PlayerEvent; - this.handlers = new PlayerEventWrapper(player); - this.config = config; - - // Set default config values - this.config.debugLoggingEnabled = this.config.debugLoggingEnabled || false; - this.logger = new Html5Logging(); - this.sessionKey = Conviva.Constants.NO_SESSION_KEY; - this.isAdBreak = false; + this.registerPlayerEvents(); - const deviceMetadataFromConfig = this.config.deviceMetadata || {}; - const deviceMetadata: Conviva.ConvivaDeviceMetadata = { - [Conviva.Constants.DeviceMetadata.CATEGORY]: - deviceMetadataFromConfig.category || this.config.deviceCategory || Conviva.Constants.DeviceCategory.WEB, - [Conviva.Constants.DeviceMetadata.BRAND]: deviceMetadataFromConfig.brand, - [Conviva.Constants.DeviceMetadata.MANUFACTURER]: deviceMetadataFromConfig.manufacturer, - [Conviva.Constants.DeviceMetadata.MODEL]: deviceMetadataFromConfig.model, - [Conviva.Constants.DeviceMetadata.TYPE]: deviceMetadataFromConfig.type, - [Conviva.Constants.DeviceMetadata.VERSION]: deviceMetadataFromConfig.version, - [Conviva.Constants.DeviceMetadata.OS_NAME]: deviceMetadataFromConfig.osName, - [Conviva.Constants.DeviceMetadata.OS_VERSION]: deviceMetadataFromConfig.osVersion, + this.convivaSsaiAnalytics = new ConvivaAnalyticsSsai(this.convivaAnalyticsTracker); + + // Do not expose `reset` method to the public API. + this.ssai = { + get isAdBreakActive() { + return this.convivaSsaiAnalytics.isAdBreakActive; + }, + reportAdBreakStarted: this.convivaSsaiAnalytics.reportAdBreakStarted.bind(this.convivaSsaiAnalytics), + reportAdStarted: this.convivaSsaiAnalytics.reportAdStarted.bind(this.convivaSsaiAnalytics), + reportAdFinished: this.convivaSsaiAnalytics.reportAdFinished.bind(this.convivaSsaiAnalytics), + reportAdSkipped: this.convivaSsaiAnalytics.reportAdSkipped.bind(this.convivaSsaiAnalytics), + reportAdBreakFinished: this.convivaSsaiAnalytics.reportAdBreakFinished.bind(this.convivaSsaiAnalytics), }; - Conviva.Analytics.setDeviceMetadata(deviceMetadata); - - let callbackFunctions: Record = {}; - callbackFunctions[Conviva.Constants.CallbackFunctions.CONSOLE_LOG] = this.logger.consoleLog; - callbackFunctions[Conviva.Constants.CallbackFunctions.MAKE_REQUEST] = new Html5Http().makeRequest; - const html5Storage = new Html5Storage(); - callbackFunctions[Conviva.Constants.CallbackFunctions.SAVE_DATA] = html5Storage.saveData; - callbackFunctions[Conviva.Constants.CallbackFunctions.LOAD_DATA] = html5Storage.loadData; - callbackFunctions[Conviva.Constants.CallbackFunctions.CREATE_TIMER] = new Html5Timer().createTimer; - callbackFunctions[Conviva.Constants.CallbackFunctions.GET_EPOCH_TIME_IN_MS] = new Html5Time().getEpochTimeMs; - - const settings: Record = {}; - settings[Conviva.Constants.GATEWAY_URL] = config.gatewayUrl; - settings[Conviva.Constants.LOG_LEVEL] = this.config.debugLoggingEnabled - ? Conviva.Constants.LogLevel.DEBUG - : Conviva.Constants.LogLevel.NONE; - - Conviva.Analytics.init(customerKey, callbackFunctions, settings); - - this.contentMetadataBuilder = new ContentMetadataBuilder(this.logger); - - this.registerPlayerEvents(); } /** @@ -240,19 +74,7 @@ export class ConvivaAnalytics { * If no source was loaded and no assetName was set via updateContentMetadata this method will throw an error. */ public initializeSession(): void { - if (this.isSessionActive()) { - this.logger.consoleLog('[ ConvivaAnalytics ] There is already a session running.', Conviva.SystemSettings.LogLevel.WARNING); - return; - } - - // This could be called before source loaded. - // Without setting the asset name on the content metadata the SDK will throw errors when we initialize the session. - if (!this.player.getSource() && !this.contentMetadataBuilder.assetName) { - throw 'AssetName is missing. Load player source first or set assetName via updateContentMetadata'; - } - - this.internalInitializeSession(); - this.sessionEndedExternally = false; + this.convivaAnalyticsTracker.initializeSession(); } /** @@ -265,22 +87,13 @@ export class ConvivaAnalytics { * no longer ensure that the session is managed at the correct time. */ public endSession(): void { - if (!this.isSessionActive()) { - return; - } - - this.debugLog('[ ConvivaAnalytics ] report playback ended state'); - - if (this.isAdBreak) { - this.debugLog('[ ConvivaAdAnalytics ] report ad skipped'); - this.convivaAdAnalytics.reportAdSkipped(); - } - - this.convivaVideoAnalytics.reportPlaybackEnded(); + this.reset(); + this.convivaAnalyticsTracker.endSession(); + } - this.internalEndSession(); - this.resetContentMetadata(); - this.sessionEndedExternally = true; + private reset(): void { + this.lastAdBreakEvent = null; + this.convivaSsaiAnalytics.reset(); } /** @@ -290,21 +103,7 @@ export class ConvivaAnalytics { * @param eventAttributes a string-to-string dictionary object with arbitrary attribute keys and values */ public sendCustomApplicationEvent(eventName: string, eventAttributes: EventAttributes = {}): void { - if (!this.isSessionActive()) { - this.logger.consoleLog( - '[ ConvivaAnalytics ] cannot send application event, no active monitoring session', - Conviva.SystemSettings.LogLevel.WARNING, - ); - return; - } - - this.debugLog('[ ConvivaAnalytics ] report custom app event', { - eventName, - eventAttributes, - }); - // NOTE Conviva has event attribute capped and 256 bytes for custom events and will show up as a warning - // in monitoring session if greater than 256 bytes - this.convivaVideoAnalytics.reportAppEvent(eventName, eventAttributes); + this.convivaAnalyticsTracker.sendCustomApplicationEvent(eventName, eventAttributes); } /** @@ -314,21 +113,7 @@ export class ConvivaAnalytics { * @param eventAttributes a string-to-string dictionary object with arbitrary attribute keys and values */ public sendCustomPlaybackEvent(eventName: string, eventAttributes: EventAttributes = {}): void { - if (!this.isSessionActive()) { - this.logger.consoleLog( - '[ ConvivaAnalytics ] cannot send playback event, no active monitoring session', - Conviva.SystemSettings.LogLevel.WARNING, - ); - return; - } - - this.debugLog('[ ConvivaAnalytics ] report custom playback event', { - eventName, - eventAttributes, - }); - // NOTE Conviva has event attribute capped and 256 bytes for custom events and will show up as a warning - // in monitoring session if greater than 256 bytes - this.convivaVideoAnalytics.reportPlaybackEvent(eventName, eventAttributes); + this.convivaAnalyticsTracker.sendCustomPlaybackEvent(eventName, eventAttributes); } /** @@ -342,7 +127,7 @@ export class ConvivaAnalytics { * @see ContentMetadataBuilder for more information about permitted attributes */ public updateContentMetadata(metadataOverrides: Partial) { - this.internalUpdateContentMetadata(metadataOverrides); + this.convivaAnalyticsTracker.updateContentMetadata(metadataOverrides); } /** @@ -358,785 +143,196 @@ export class ConvivaAnalytics { severity: Conviva.valueof, endSession: boolean = true, ) { - if (!this.isSessionActive()) { - return; - } - - this.debugLog('[ ConvivaAnalytics ] report playback failed', { - message, - }); - this.convivaVideoAnalytics.reportPlaybackFailed(message); - if (endSession) { - this.internalEndSession(); - this.resetContentMetadata(); - } + this.convivaAnalyticsTracker.reportPlaybackDeficiency(message, severity, endSession); } /** * Puts the session state in a notMonitored state. */ public pauseTracking(): void { - this.debugLog('[ ConvivaAnalytics ] pause tracking via ad break started reporting'); - // AdStart is the right way to pause monitoring according to conviva. - this.convivaVideoAnalytics.reportAdBreakStarted( - Conviva.Constants.AdType.CLIENT_SIDE, - Conviva.Constants.AdPlayer.SEPARATE, - ); + this.convivaAnalyticsTracker.pauseTracking(); } /** * Puts the session state from a notMonitored state into the last one tracked. */ public resumeTracking(): void { - this.debugLog('[ ConvivaAnalytics ] resume tracking via ad break ended reporting'); - // AdEnd is the right way to resume monitoring according to conviva. - this.convivaVideoAnalytics.reportAdBreakEnded(); + this.convivaAnalyticsTracker.resumeTracking(); } public release(): void { this.destroy(); + this.convivaAnalyticsTracker.release(); } private destroy(event?: PlayerEventBase): void { + this.reset(); this.unregisterPlayerEvents(); - this.internalEndSession(event); - - Conviva.Analytics.release(); + this.convivaAnalyticsTracker.release(event); } private debugLog(message?: any, ...optionalParams: any[]): void { - if (this.config.debugLoggingEnabled) { + if (this.debugLoggingEnabled) { console.log.apply(console, arguments); } } - private getUrlFromSource(source: SourceConfig): string { - switch (this.player.getStreamType()) { - case 'dash': - return source.dash; - case 'hls': - return source.hls; - case 'progressive': - if (Array.isArray(source.progressive)) { - // TODO check if the first stream can be another index (e.g. ordered by bitrate), and select the current - // startup url - return source.progressive[0].url; - } else { - return source.progressive; - } - } - } - - private internalUpdateContentMetadata(metadataOverrides: Partial) { - this.contentMetadataBuilder.setOverrides(metadataOverrides); - - if (!this.isSessionActive()) { - this.logger.consoleLog( - '[ ConvivaAnalytics ] no active session. Content metadata will be propagated to Conviva on session initialization.', - Conviva.SystemSettings.LogLevel.DEBUG, - ); - return; - } - - this.buildContentMetadata(); - this.updateSession(); - } - - /** - * A Conviva Session should only be initialized when there is a source provided in the player because - * Conviva only allows to update different `contentMetadata` only at different times. - * - * The session should be created as soon as there was a play intention from the user. - * - * Set only once: - * - assetName - * - * Update before first video frame: - * - viewerId - * - streamType - * - playerName - * - duration - * - custom - * - * Multiple updates during session: - * - streamUrl - * - defaultResource (unused) - * - encodedFrameRate (unused) - */ - private internalInitializeSession() { - this.buildContentMetadata(); - - // Create a Conviva monitoring session. - this.convivaVideoAnalytics = Conviva.Analytics.buildVideoAnalytics(); - this.convivaAdAnalytics = Conviva.Analytics.buildAdAnalytics(this.convivaVideoAnalytics); - - const playerInfo = { - [Conviva.Constants.FRAMEWORK_NAME]: 'Bitmovin Player', - [Conviva.Constants.FRAMEWORK_VERSION]: this.player.version, - }; - - this.convivaVideoAnalytics.setPlayerInfo(playerInfo); - this.convivaAdAnalytics.setAdPlayerInfo(playerInfo); - - this.debugLog('[ ConvivaAnalytics ] report playback requested'); - this.convivaVideoAnalytics.reportPlaybackRequested(this.contentMetadataBuilder.build()); - - this.sessionKey = this.convivaVideoAnalytics.getSessionId(); - - this.convivaVideoAnalytics.setCallback(() => { - const playheadTimeMs = this.player.getCurrentTime('relativetime' as TimeMode) * 1000; - - if (this.isAdBreak) { - this.debugLog('[ ConvivaAdAnalytics ] report ad player head time', playheadTimeMs); - this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.PLAY_HEAD_TIME, playheadTimeMs); - } else { - this.debugLog('[ ConvivaAnalytics ] report player head time', playheadTimeMs); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.PLAY_HEAD_TIME, playheadTimeMs); - } - }); - - this.debugLog('[ ConvivaAnalytics ] start session', this.sessionKey); - - if (!this.isSessionActive()) { - // Something went wrong. With stable system interfaces, this should never happen. - this.logger.consoleLog( - '[ ConvivaAnalytics ] Something went wrong, could not obtain session key', - Conviva.SystemSettings.LogLevel.ERROR, - ); - } - - // Send the session init audio language values. - this.updateAudioTrack(this.player.getAudio()); - - // Check if at session init has a subtitle enabled. - this.checkSubtitleWhenInternalInitialize(); - } - - /** - * Update contentMetadata which must be present before first video frame - */ - private buildContentMetadata() { - this.contentMetadataBuilder.duration = this.player.getDuration(); - this.contentMetadataBuilder.streamType = this.player.isLive() - ? Conviva.ContentMetadata.StreamType.LIVE - : Conviva.ContentMetadata.StreamType.VOD; - - this.contentMetadataBuilder.addToCustom({ - // Autoplay and preload are important options for the Video Startup Time so we track it as custom tags - autoplay: PlayerConfigHelper.getAutoplayConfig(this.player) + '', - preload: PlayerConfigHelper.getPreloadConfig(this.player) + '', - integrationVersion: ConvivaAnalytics.VERSION, - }); - - const source = this.player.getSource(); - - // This could be called before we got a source - if (source) { - this.buildSourceRelatedMetadata(source); - } - } - - private buildSourceRelatedMetadata(source: SourceConfig) { - this.contentMetadataBuilder.assetName = this.getAssetNameFromSource(source); - this.contentMetadataBuilder.viewerId = this.contentMetadataBuilder.viewerId; - this.contentMetadataBuilder.addToCustom({ - playerType: this.player.getPlayerType(), - streamType: this.player.getStreamType(), - vrContentType: source.vr && source.vr.contentType, - }); - - this.contentMetadataBuilder.streamUrl = this.getUrlFromSource(source); - } - - private updateSession() { - if (!this.isSessionActive()) { - return; - } - - this.convivaVideoAnalytics.setContentInfo(this.contentMetadataBuilder.build()); - } - - private getAssetNameFromSource(source: SourceConfig): string { - let assetName; - - const assetTitle = source.title; - if (assetTitle) { - assetName = assetTitle; - } else { - assetName = 'Untitled (no source.title set)'; - } - - return assetName; - } - - private internalEndSession = (event?: PlayerEventBase) => { - if (!this.isSessionActive()) { - return; - } - - this.debugLog('[ ConvivaAnalytics ] end session', Conviva.Constants.NO_SESSION_KEY, event); - - this.convivaVideoAnalytics.release(); - this.convivaVideoAnalytics = null; - - this.convivaAdAnalytics.release(); - this.convivaAdAnalytics = null; - - this.lastAdBreakEvent = null; - - this.isAdBreak = false; - }; - - private resetContentMetadata(): void { - this.contentMetadataBuilder.reset(); - } - - private isSessionActive(): boolean { - return !!this.convivaVideoAnalytics; - } - private onPlaybackStateChanged = (event: PlayerEventBase) => { - this.debugLog('[ Player Event ] playback state change related event', event); - - if (!this.isSessionActive()) { - return; - } - - let playerState; - - switch (event.type) { - case this.events.StallStarted: - playerState = Conviva.Constants.PlayerState.BUFFERING; - break; - case this.events.Playing: - playerState = Conviva.Constants.PlayerState.PLAYING; - break; - case this.events.Paused: - playerState = Conviva.Constants.PlayerState.PAUSED; - break; - case this.events.Seeked: - case this.events.TimeShifted: - case this.events.StallEnded: - if (this.player.isPlaying()) { - playerState = Conviva.Constants.PlayerState.PLAYING; - } else { - playerState = Conviva.Constants.PlayerState.PAUSED; - } - break; - } - - const stallTrackingStartEvents = [ - this.events.Play, - this.events.Seek, - this.events.TimeShift, - ]; - const stallTrackingClearEvents = [ - this.events.StallStarted, - this.events.Playing, - this.events.Paused, - this.events.Seeked, - this.events.TimeShifted, - this.events.StallEnded, - this.events.PlaybackFinished, - ]; - - if (stallTrackingStartEvents.indexOf(event.type) !== -1) { - this.stallTrackingTimeout.start(); - } else if (stallTrackingClearEvents.indexOf(event.type) !== -1) { - this.stallTrackingTimeout.clear(); - } - - - if (playerState) { - if (this.isAdBreak) { - this.debugLog('[ ConvivaAdAnalytics ] report ad playback state', playerState); - this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.PLAYER_STATE, playerState); - } else { - this.debugLog('[ ConvivaAnalytics ] report playback state', playerState); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.PLAYER_STATE, playerState); - } - } - - if (event.type === this.events.PlaybackFinished) { - this.debugLog('[ ConvivaAnalytics ] report playback ended'); - this.convivaVideoAnalytics.reportPlaybackEnded(); - } - }; - - private onSourceLoaded = (event: PlayerEventBase) => { - this.debugLog('[ Player Event ] source loaded', event); - - // In case the session was created external before loading the source - if (!this.isSessionActive()) { - return; - } - - this.buildSourceRelatedMetadata(this.player.getSource()); - this.updateSession(); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] playback state change related event', event); + this.convivaAnalyticsTracker.trackPlaybackStateChanged(event); }; private onPlay = (event: PlaybackEvent) => { - this.debugLog('[ Player Event ] play', event); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] play', event); - if (this.isAdBreak) { - // Do not track play event during ad (e.g. triggered from IMA) + if (!this.convivaAnalyticsTracker.canTrackPlayEvent) { return; } - // in case the playback has finished and the user replays the stream create a new session - if (!this.isSessionActive() && !this.sessionEndedExternally) { - this.internalInitializeSession(); - } - this.onPlaybackStateChanged(event); }; private onPlaying = (event: PlaybackEvent) => { - this.contentMetadataBuilder.setPlaybackStarted(true); - this.debugLog('[ Player Event ] playing', event); - this.updateSession(); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] playing', event); this.onPlaybackStateChanged(event); }; private onPlaybackFinished = (event: PlayerEventBase) => { - this.debugLog('[ Player Event ] playback finished', event); - - if (!this.isSessionActive()) { - return; - } - + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] playback finished', event); this.onPlaybackStateChanged(event); - - this.convivaVideoAnalytics.release(); - this.convivaVideoAnalytics = null; - - this.convivaAdAnalytics.release(); - this.convivaAdAnalytics = null; }; private onVideoQualityChanged = (event: VideoQualityChangedEvent) => { - this.debugLog('[ Player Event ] video quality changed', event); - - // We calculate the bitrate with a divisor of 1000 so the values look nicer - // Example: 250000 / 1000 => 250 kbps (250000 / 1024 => 244kbps) - const bitrateKbps = Math.round(event.targetQuality.bitrate / 1000); - - this.debugLog('[ ConvivaAnalytics ] report bitrate', { - event, - bitrateKbps, - }); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.BITRATE, bitrateKbps); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] video quality changed', event); + this.convivaAnalyticsTracker.trackVideoQualityChanged(event); }; private onCustomEvent = (event: PlayerEventBase) => { - this.debugLog('[ Player Event ] custom playback related event', event); - - if (!this.isSessionActive()) { - this.debugLog('[ ConvivaAnalytics ] skip custom event, no session existing', event); - return; - } - + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] custom playback related event', event); const eventAttributes = ObjectUtils.flatten(event); this.sendCustomPlaybackEvent(event.type, eventAttributes); }; private onAdBreakStarted = (event: AdBreakEvent) => { - this.debugLog('[ Player Event ] adbreak started', event); - - this.isAdBreak = true; + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] adbreak started', event); this.lastAdBreakEvent = event; - - this.debugLog('[ ConvivaAnalytics ] report ad break started', event); - this.convivaVideoAnalytics.reportAdBreakStarted( - Conviva.Constants.AdType.CLIENT_SIDE, - Conviva.Constants.AdPlayer.SEPARATE, - ); + this.convivaAnalyticsTracker.trackAdBreakStarted(Conviva.Constants.AdType.CLIENT_SIDE); }; private onAdStarted = (event: AdEvent) => { - this.debugLog('[ Player Event ] ad started', event); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] ad started', event); - const adInfo = AdHelper.extractConvivaAdInfo(this.player, this.lastAdBreakEvent, event); + const adInfo = AdHelper.extractCsaiConvivaAdInfo(this.player, this.lastAdBreakEvent, event); const bitrateKbps = event.ad.data?.bitrate; - this.debugLog('[ ConvivaAdAnalytics ] report ad started', { - event, - adInfo, - }); - this.convivaAdAnalytics.reportAdStarted(adInfo); - - this.debugLog('[ ConvivaAdAnalytics ] report playing ad playback state'); - this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.PLAYER_STATE, Conviva.Constants.PlayerState.PLAYING); - - if (bitrateKbps) { - this.debugLog('[ ConvivaAdAnalytics ] report ad bitrate', bitrateKbps); - this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.BITRATE, bitrateKbps); - } + this.convivaAnalyticsTracker.trackAdStarted(adInfo, Conviva.Constants.AdType.CLIENT_SIDE, bitrateKbps); } private onAdFinished = (event: AdEvent) => { - this.debugLog('[ Player Event ] ad finished', event); - - this.debugLog('[ ConvivaAdAnalytics ] report ad ended', { - event, - }); - this.convivaAdAnalytics.reportAdEnded(); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] ad finished', event); + this.convivaAnalyticsTracker.trackAdFinished(); } private onAdSkipped = (event: AdEvent) => { - this.debugLog('[ Player Event ] ad skipped', event); - - this.debugLog('[ ConvivaAdAnalytics ] report ad skipped', event); - this.convivaAdAnalytics.reportAdSkipped(); - + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] ad skipped', event); + this.convivaAnalyticsTracker.trackAdSkipped(); this.onCustomEvent(event); }; - private onAdBreakFinished = (event: AdBreakEvent | ErrorEvent) => { - this.debugLog('[ Player Event ] adbreak finished', event); - this.isAdBreak = false; - - this.debugLog('[ ConvivaAnalytics ] report ad break ended', event); - this.convivaVideoAnalytics.reportAdBreakEnded(); - - this.debugLog('[ ConvivaAnalytics ] report playing playback state'); - this.convivaVideoAnalytics.reportPlaybackMetric( - Conviva.Constants.Playback.PLAYER_STATE, - Conviva.Constants.PlayerState.PLAYING, - ); + private onAdBreakFinished = (event: AdBreakEvent) => { + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] adbreak finished', event); + this.convivaAnalyticsTracker.trackAdBreakFinished(); }; private onAdError = (event: ErrorEvent) => { - this.debugLog('[ Player Event ] ad error', event); - - const formattedError = AdHelper.formatAdErrorEvent(event); - - this.debugLog('[ ConvivaAdAnalytics ] report ad error', { - event, - formattedError, - }); - this.convivaAdAnalytics.reportAdError(formattedError, Conviva.Constants.ErrorSeverity.WARNING); - + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] ad error', event); + this.convivaAnalyticsTracker.trackAdError(event); this.onCustomEvent(event); }; private onSeek = (event: SeekEvent) => { - this.debugLog('[ Player Event ] seek', event); - - if (!this.isSessionActive()) { - // Handle the case that the User seeks on the UI before play was triggered. - // This also handles startTime feature. The same applies for onTimeShift. - return; - } - - this.trackSeekStart(event.seekTarget); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] seek', event); + this.convivaAnalyticsTracker.trackSeekStart(event.seekTarget); this.onPlaybackStateChanged(event); }; private onSeeked = (event: SeekEvent) => { - this.debugLog('[ Player Event ] seeked', event); - - if (!this.isSessionActive()) { - // See comment in onSeek - return; - } - - this.trackSeekEnd(); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] seeked', event); + this.convivaAnalyticsTracker.trackSeekEnd(); this.onPlaybackStateChanged(event); }; private onTimeShift = (event: TimeShiftEvent) => { - this.debugLog('[ Player Event ] time shift', event); - - if (!this.isSessionActive()) { - // See comment in onSeek - return; - } - + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] time shift', event); // According to conviva it is valid to pass -1 for seeking in live streams - this.trackSeekStart(-1); + this.convivaAnalyticsTracker.trackSeekStart(-1); this.onPlaybackStateChanged(event); }; private onTimeShifted = (event: TimeShiftEvent) => { - this.debugLog('[ Player Event ] time shifted', event); - - if (!this.isSessionActive()) { - // See comment in onSeek - return; - } - - this.trackSeekEnd(); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] time shifted', event); + this.convivaAnalyticsTracker.trackSeekEnd(); this.onPlaybackStateChanged(event); }; - private trackSeekStart(target: number) { - this.debugLog('[ ConvivaAnalytics ] report seek started'); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SEEK_STARTED); - } - - private trackSeekEnd() { - this.debugLog('[ ConvivaAnalytics ] report seek ended'); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SEEK_ENDED); - } private onAudioChanged = (event: AudioChangedEvent) => { - this.debugLog('[ Player Event ] audio changed', event); - - if (!this.isSessionActive()) { - // Handle the case that the User change audio on the UI before play was triggered. - return; - } - - this.updateAudioTrack(event.targetAudio); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] audio changed', event); + this.convivaAnalyticsTracker.trackUpdateAudioTrack(event.targetAudio); }; - private updateAudioTrack(audioTrack: AudioTrack) { - const formattedAudio = - audioTrack.lang !== 'unknown' ? '[' + audioTrack.lang + ']:' + audioTrack.label : audioTrack.label; - - this.debugLog('[ ConvivaAnalytics ] report audio language', { - formattedAudio, - }); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.AUDIO_LANGUAGE, formattedAudio); - } - private onSubtitleEnabled = (event: SubtitleEvent) => { - this.debugLog('[ Player Event ] subtitled enabled', event); - - if (!this.isSessionActive()) { - // Handle the case that the User change subtitle on the UI before play was triggered. - return; - } - this.updateSubtitleTrack(event.subtitle); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] subtitled enabled', event); + this.convivaAnalyticsTracker.trackUpdateSubtitleTrack(event.subtitle); }; - private updateSubtitleTrack(subtitleTrack: SubtitleTrack) { - const formattedSubtitle = - subtitleTrack.lang !== 'unknown' ? '[' + subtitleTrack.lang + ']:' + subtitleTrack.label : subtitleTrack.label; - - if (subtitleTrack.kind === 'subtitles') { - this.debugLog('[ ConvivaAnalytics ] report subtitles language', { - formattedSubtitle, - }); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SUBTITLES_LANGUAGE, formattedSubtitle); - - this.debugLog('[ ConvivaAnalytics ] report off closed captions language'); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.CLOSED_CAPTIONS_LANGUAGE, 'off'); - } else if (subtitleTrack.kind === 'captions') { - this.debugLog('[ ConvivaAnalytics ] report closed captions language', { - formattedSubtitle, - }); - this.convivaVideoAnalytics.reportPlaybackMetric( - Conviva.Constants.Playback.CLOSED_CAPTIONS_LANGUAGE, - formattedSubtitle, - ); - - this.debugLog('[ ConvivaAnalytics ] report off subtitles language'); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SUBTITLES_LANGUAGE, 'off'); - } else { - this.turnOffSubtitles(); - } - } - private onSubtitleDisabled = (event: SubtitleEvent) => { - this.debugLog('[ Player Event ] subtitles disabled', event); - - if (!this.isSessionActive()) { - // Handle the case that the User turn off subtitle on the UI before play was triggered. - return; - } - - this.turnOffSubtitles(); + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] subtitles disabled', event); + this.convivaAnalyticsTracker.trackTurnOffSubtitles(); }; - private checkSubtitleWhenInternalInitialize() { - if (this.player.subtitles !== undefined) { - const enableSubtitle = this.player.subtitles.list().filter((i) => i.enabled); - - // Send the session init subtitle language values. - if (enableSubtitle.length === 1) { - this.updateSubtitleTrack(enableSubtitle[0]); - return; - } - } - - this.turnOffSubtitles(); - } - - private turnOffSubtitles() { - this.debugLog('[ ConvivaAnalytics ] report off subtitles language'); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SUBTITLES_LANGUAGE, 'off'); - - this.debugLog('[ ConvivaAnalytics ] report off closed captions language'); - this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.CLOSED_CAPTIONS_LANGUAGE, 'off'); - } - private onError = (event: ErrorEvent) => { - this.debugLog('[ Player Event ] error', event); - - if (!this.isSessionActive() && !this.sessionEndedExternally) { - // initialize Session if not yet initialized to capture Video Start Failures - this.internalInitializeSession(); - } - - this.reportPlaybackDeficiency(String(event.code) + ' ' + event.name, Conviva.Constants.ErrorSeverity.FATAL); - }; - - private onSourceUnloaded = (event: PlayerEventBase) => { - this.debugLog('[ Player Event ] source unloaded', event); - - if (this.isAdBreak) { - // Ignore sourceUnloaded events during ads - return; - } else { - this.internalEndSession(event); - this.resetContentMetadata(); - } + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] error', event); + this.convivaAnalyticsTracker.trackError(event); }; private onDestroy = (event: any) => { - this.debugLog('[ Player Event ] destroy', event); - + this.debugLog('[ ConvivaAnalytics ] [ Player Event ] destroy', event); this.destroy(event); }; private registerPlayerEvents(): void { - const playerEvents = this.handlers; - - playerEvents.add(this.events.SourceLoaded, this.onSourceLoaded); - playerEvents.add(this.events.Play, this.onPlay); - playerEvents.add(this.events.Playing, this.onPlaying); - playerEvents.add(this.events.Paused, this.onPlaybackStateChanged); - playerEvents.add(this.events.StallStarted, this.onPlaybackStateChanged); - playerEvents.add(this.events.StallEnded, this.onPlaybackStateChanged); - playerEvents.add(this.events.PlaybackFinished, this.onPlaybackFinished); - playerEvents.add(this.events.VideoPlaybackQualityChanged, this.onVideoQualityChanged); - playerEvents.add(this.events.AudioPlaybackQualityChanged, this.onCustomEvent); - playerEvents.add(this.events.Muted, this.onCustomEvent); - playerEvents.add(this.events.Unmuted, this.onCustomEvent); - playerEvents.add(this.events.ViewModeChanged, this.onCustomEvent); - playerEvents.add(this.events.AdStarted, this.onAdStarted); - playerEvents.add(this.events.AdFinished, this.onAdFinished); - playerEvents.add(this.events.AdBreakStarted, this.onAdBreakStarted); - playerEvents.add(this.events.AdBreakFinished, this.onAdBreakFinished); - playerEvents.add(this.events.AdSkipped, this.onAdSkipped); - playerEvents.add(this.events.AdError, this.onAdError); - playerEvents.add(this.events.SourceUnloaded, this.onSourceUnloaded); - playerEvents.add(this.events.Error, this.onError); - playerEvents.add(this.events.Destroy, this.onDestroy); - playerEvents.add(this.events.Seek, this.onSeek); - playerEvents.add(this.events.Seeked, this.onSeeked); - playerEvents.add(this.events.TimeShift, this.onTimeShift); - playerEvents.add(this.events.TimeShifted, this.onTimeShifted); - playerEvents.add(this.events.AudioChanged, this.onAudioChanged); - playerEvents.add(this.events.SubtitleEnabled, this.onSubtitleEnabled); - playerEvents.add(this.events.SubtitleDisabled, this.onSubtitleDisabled); - - playerEvents.add(this.events.CastStarted, this.onCustomEvent); - playerEvents.add(this.events.CastStopped, this.onCustomEvent); + this.handlers.add(this.events.Play, this.onPlay); + this.handlers.add(this.events.Playing, this.onPlaying); + this.handlers.add(this.events.Paused, this.onPlaybackStateChanged); + this.handlers.add(this.events.StallStarted, this.onPlaybackStateChanged); + this.handlers.add(this.events.StallEnded, this.onPlaybackStateChanged); + this.handlers.add(this.events.PlaybackFinished, this.onPlaybackFinished); + this.handlers.add(this.events.VideoPlaybackQualityChanged, this.onVideoQualityChanged); + this.handlers.add(this.events.AudioPlaybackQualityChanged, this.onCustomEvent); + this.handlers.add(this.events.Muted, this.onCustomEvent); + this.handlers.add(this.events.Unmuted, this.onCustomEvent); + this.handlers.add(this.events.ViewModeChanged, this.onCustomEvent); + this.handlers.add(this.events.AdStarted, this.onAdStarted); + this.handlers.add(this.events.AdFinished, this.onAdFinished); + this.handlers.add(this.events.AdBreakStarted, this.onAdBreakStarted); + this.handlers.add(this.events.AdBreakFinished, this.onAdBreakFinished); + this.handlers.add(this.events.AdSkipped, this.onAdSkipped); + this.handlers.add(this.events.AdError, this.onAdError); + this.handlers.add(this.events.Error, this.onError); + this.handlers.add(this.events.Destroy, this.onDestroy); + this.handlers.add(this.events.Seek, this.onSeek); + this.handlers.add(this.events.Seeked, this.onSeeked); + this.handlers.add(this.events.TimeShift, this.onTimeShift); + this.handlers.add(this.events.TimeShifted, this.onTimeShifted); + this.handlers.add(this.events.AudioChanged, this.onAudioChanged); + this.handlers.add(this.events.SubtitleEnabled, this.onSubtitleEnabled); + this.handlers.add(this.events.SubtitleDisabled, this.onSubtitleDisabled); + + this.handlers.add(this.events.CastStarted, this.onCustomEvent); + this.handlers.add(this.events.CastStopped, this.onCustomEvent); } private unregisterPlayerEvents(): void { this.handlers.clear(); } - - static get version(): string { - return ConvivaAnalytics.VERSION; - } -} - -class PlayerConfigHelper { - /** - * The config for autoplay and preload have great impact to the VST (Video Startup Time) we track it. - * Since there is no way to get default config values from the player they are hardcoded. - */ - public static AUTOPLAY_DEFAULT_CONFIG: boolean = false; - - /** - * Extract autoplay config form player - * - * @param player: Player - */ - public static getAutoplayConfig(player: Player): boolean { - const playerConfig = player.getConfig(); - - if (playerConfig.playback && playerConfig.playback.autoplay !== undefined) { - return playerConfig.playback.autoplay; - } else { - return PlayerConfigHelper.AUTOPLAY_DEFAULT_CONFIG; - } - } - - /** - * Extract preload config from player - * - * The preload config can be set individual for mobile or desktop as well as on root level for both platforms. - * Default value is true for VOD and false for live streams. If the value is not set for current platform or on root - * level the default value will be used over the value for the other platform. - * - * @param player: Player - */ - public static getPreloadConfig(player: Player): boolean { - const playerConfig = player.getConfig(); - - if (BrowserUtils.isMobile()) { - if ( - playerConfig.adaptation && - playerConfig.adaptation.mobile && - playerConfig.adaptation.mobile.preload !== undefined - ) { - return playerConfig.adaptation.mobile.preload; - } - } else { - if ( - playerConfig.adaptation && - playerConfig.adaptation.desktop && - playerConfig.adaptation.desktop.preload !== undefined - ) { - return playerConfig.adaptation.desktop.preload; - } - } - - if (playerConfig.adaptation && playerConfig.adaptation.preload !== undefined) { - return playerConfig.adaptation.preload; - } - - return !player.isLive(); - } -} - -class PlayerEventWrapper { - private player: Player; - private readonly eventHandlers: { [eventType: string]: Array<(event?: PlayerEventBase) => void> }; - - constructor(player: Player) { - this.player = player; - this.eventHandlers = {}; - } - - public add(eventType: PlayerEvent, callback: (event?: PlayerEventBase) => void): void { - this.player.on(eventType, callback); - - if (!this.eventHandlers[eventType]) { - this.eventHandlers[eventType] = []; - } - - this.eventHandlers[eventType].push(callback); - } - - public remove(eventType: PlayerEvent, callback: (event?: PlayerEventBase) => void): void { - this.player.off(eventType, callback); - - if (this.eventHandlers[eventType]) { - ArrayUtils.remove(this.eventHandlers[eventType], callback); - } - } - - public clear(): void { - for (const eventType in this.eventHandlers) { - for (const callback of this.eventHandlers[eventType]) { - this.remove(eventType as PlayerEvent, callback); - } - } - } } diff --git a/src/ts/ConvivaAnalyticsSsai.ts b/src/ts/ConvivaAnalyticsSsai.ts new file mode 100644 index 0000000..449cf51 --- /dev/null +++ b/src/ts/ConvivaAnalyticsSsai.ts @@ -0,0 +1,94 @@ +import * as Conviva from '@convivainc/conviva-js-coresdk'; +import { ConvivaAnalyticsTracker, INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG } from './ConvivaAnalyticsTracker'; +import { AdHelper, SsaiAdInfo } from './helper/AdHelper'; + +export class ConvivaAnalyticsSsai { + private readonly convivaAnalyticsTracker: ConvivaAnalyticsTracker; + + constructor(convivaAnalyticsTracker: ConvivaAnalyticsTracker) { + this.convivaAnalyticsTracker = convivaAnalyticsTracker; + } + + private _isAdBreakActive: boolean = false; + + /** + * Reports if a server-side ad break is currently active. + * + * @return true if a server-side ad break is active, false otherwise. + */ + public get isAdBreakActive() { + return this._isAdBreakActive; + } + + public reset() { + this._isAdBreakActive = false; + } + + /** + * Reports the start of a server-side ad break. Must be called before the first ad starts. + * Has no effect if a server-side ad break is already playing. + */ + public reportAdBreakStarted() { + if (this.convivaAnalyticsTracker.isAdBreakActive || this._isAdBreakActive) { + return; + } + + this._isAdBreakActive = true; + this.convivaAnalyticsTracker.trackAdBreakStarted(Conviva.Constants.AdType.SERVER_SIDE); + } + + /** + * Reports the start of a server-side ad. + *

+ * Has to be called after calling the reportAdBreakStarted method. + * + * @param ssaiAdInfo Object containing metadata about the server-side ad. + */ + public reportAdStarted(ssaiAdInfo: SsaiAdInfo) { + if (!this._isAdBreakActive) { + return; + } + + this.convivaAnalyticsTracker.trackAdStarted( + AdHelper.convertSsaiAdInfoToConvivaAdInfo(ssaiAdInfo, this.convivaAnalyticsTracker.getContentMetadata()), + Conviva.Constants.AdType.SERVER_SIDE, + ); + } + + /** + * Reports the end of a server-side ad. + * Has no effect if no server-side ad is currently playing. + */ + public reportAdFinished() { + if (!this.isAdBreakActive) { + return; + } + + this.convivaAnalyticsTracker.trackAdFinished(); + } + + /** + * Reports that the current ad was skipped. + * Has no effect if no server-side ad is playing. + */ + public reportAdSkipped() { + if (!this._isAdBreakActive) { + return; + } + + this.convivaAnalyticsTracker.trackAdSkipped(); + } + + /** + * Reports the end of a server-side ad break. Must be called after the last ad has finished. + * Has no effect if no server-side ad break is currently active. + */ + public reportAdBreakFinished() { + if (!this._isAdBreakActive) { + return; + } + + this._isAdBreakActive = false; + this.convivaAnalyticsTracker.trackAdBreakFinished(); + } +} diff --git a/src/ts/ConvivaAnalyticsTracker.ts b/src/ts/ConvivaAnalyticsTracker.ts new file mode 100644 index 0000000..798966f --- /dev/null +++ b/src/ts/ConvivaAnalyticsTracker.ts @@ -0,0 +1,888 @@ +import * as Conviva from '@convivainc/conviva-js-coresdk'; +import type { + AudioTrack, + ErrorEvent, + PlaybackEvent, + PlayerAPI, + PlayerEvent, + PlayerEventBase, + SourceConfig, + VideoQualityChangedEvent, + SubtitleTrack, + TimeMode, +} from 'bitmovin-player'; +import { Html5Http } from './Html5Http'; +import { Html5Logging } from './Html5Logging'; +import { Html5Storage } from './Html5Storage'; +import { Html5Time } from './Html5Time'; +import { Html5Timer } from './Html5Timer'; +import { Timeout } from 'bitmovin-player-ui/dist/js/framework/timeout'; +import { ContentMetadataBuilder, Metadata } from './ContentMetadataBuilder'; +import { AdHelper } from './helper/AdHelper'; +import { PlayerEventWrapper } from './helper/PlayerEventWrapper'; +import { PlayerConfigHelper } from './helper/PlayerConfigHelper'; +import { PlayerStateHelper } from './helper/PlayerStateHelper'; + +export const AUTOPLAY_CONTENT_METADATA_CUSTOM_TAG = 'autoplay'; +export const PRELOAD_CONTENT_METADATA_CUSTOM_TAG = 'preload'; +export const INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG = 'integrationVersion'; + +export const PLAYER_TYPE_CONTENT_METADATA_CUSTOM_TAG = 'playerType'; +export const STREAM_TYPE_CONTENT_METADATA_CUSTOM_TAG = 'streamType'; +export const VR_CONTENT_TYPE_CONTENT_METADATA_CUSTOM_TAG = 'vrContentType'; + +export interface ConvivaAnalyticsConfiguration { + /** + * Enables debug logging when set to true (default: false). + */ + debugLoggingEnabled?: boolean; + /** + * The TOUCHSTONE_SERVICE_URL for testing with Touchstone. Only to be used for development, must not be set in + * production or automated testing. + */ + gatewayUrl?: string; + + /** + * Option to set the Conviva Device Category, which is used to assist with + * user agent string parsing by the Conviva SDK. (default: WEB) + * @deprecated Use `deviceMetadata.category` field + */ + deviceCategory?: Conviva.valueof; + + /** + * Option to override the Conviva Device Metadata. + * (Default: Auto extract all options from User Agent string) + */ + deviceMetadata?: { + /** + * Option to set the Conviva Device Category, which is used to assist with + * user agent string parsing by the Conviva SDK. + * (default: The same specified in config.deviceCategory) + */ + category?: Conviva.valueof; + + /** + * Option to override the Conviva Device Brand. + * (Default: Auto extract from User Agent string) + */ + brand?: string; + + /** + * Option to override the Conviva Device Manufacturer. + * (Default: Auto extract from User Agent string) + */ + manufacturer?: string; + + /** + * Option to override the Conviva Device Model. + * (Default: Auto extract from User Agent string) + */ + model?: string; + + /** + * Option to override the Conviva Device Type + * (Default: Auto extract from User Agent string) + */ + type?: Conviva.valueof; + + /** + * Option to override the Conviva Device Version. + * (Default: Auto extract from User Agent string) + */ + version?: string; + + /** + * Option to override the Conviva Operating System Name + * (Default: Auto extract from User Agent string) + */ + osName?: string; + + /** + * Option to override the Conviva Operating System Version + * (Default: Auto extract from User Agent string) + */ + osVersion?: string; + }; +} + +export interface EventAttributes { + [key: string]: string; +} + +export class ConvivaAnalyticsTracker { + private static readonly VERSION: string = '{{VERSION}}'; + + private static readonly STALL_TRACKING_DELAY_MS = 100; + private readonly player: PlayerAPI; + private readonly events: typeof PlayerEvent; + private readonly handlers: PlayerEventWrapper; + private readonly config: ConvivaAnalyticsConfiguration; + private readonly contentMetadataBuilder: ContentMetadataBuilder; + + private readonly logger: Conviva.LoggingInterface; + private sessionKey: number; + private convivaVideoAnalytics: Conviva.VideoAnalytics; + private convivaAdAnalytics: Conviva.AdAnalytics; + + /** + * Tracks the ad break status and is true between ON_AD_STARTED and ON_AD_FINISHED/SKIPPED/ERROR. + * This flag is required because player.isAd() is unreliable and not always true between the events. + */ + private _isAdBreakActive: boolean; + + public get isAdBreakActive(): boolean { + return this._isAdBreakActive; + } + + /** + * Do not track play event during ad (e.g. triggered from IMA) + */ + public get canTrackPlayEvent(): boolean { + return !this._isAdBreakActive; + } + + public getContentMetadata() { + return this.contentMetadataBuilder.build(); + } + + // Since there are no stall events during play / playing; seek / seeked; timeShift / timeShifted we need + // to track stalling state between those events. To prevent tracking eg. when seeking in buffer we delay it. + private stallTrackingTimeout: Timeout = new Timeout(ConvivaAnalyticsTracker.STALL_TRACKING_DELAY_MS, () => { + if (this._isAdBreakActive) { + this.debugLog('[ ConvivaAnalyticsTracker ] report buffering ad playback state'); + this.convivaAdAnalytics.reportAdMetric( + Conviva.Constants.Playback.PLAYER_STATE, + Conviva.Constants.PlayerState.BUFFERING, + ); + } else { + this.debugLog('[ ConvivaAnalyticsTracker ] report buffering playback state'); + this.convivaVideoAnalytics.reportPlaybackMetric( + Conviva.Constants.Playback.PLAYER_STATE, + Conviva.Constants.PlayerState.BUFFERING, + ); + } + }); + + /** + * Boolean to track whether a session was ended by an upstream caller instead of within internal session management. + * If this is true, we should avoid initializing a new session internally if a session is not active + */ + private sessionEndedExternally = false; + + constructor(player: PlayerAPI, customerKey: string, config: ConvivaAnalyticsConfiguration = {}) { + if (typeof Conviva === 'undefined') { + console.error( + `Conviva script missing, cannot init ConvivaAnalytics. Please load the Conviva script (conviva-core-sdk.min.js) before Bitmovin's ConvivaAnalytics integration.`, + ); + return; // Cancel initialization + } + + if (player.getSource()) { + console.error('Bitmovin Conviva integration must be instantiated before calling player.load()'); + return; // Cancel initialization + } + + this.player = player; + + // TODO: Use alternative to deprecated player.exports + this.events = player.exports.PlayerEvent; + + this.handlers = new PlayerEventWrapper(player); + this.config = config; + + // Set default config values + this.config.debugLoggingEnabled = this.config.debugLoggingEnabled || false; + + this.logger = new Html5Logging(); + this.sessionKey = Conviva.Constants.NO_SESSION_KEY; + this._isAdBreakActive = false; + + const deviceMetadataFromConfig = this.config.deviceMetadata || {}; + const deviceMetadata: Conviva.ConvivaDeviceMetadata = { + [Conviva.Constants.DeviceMetadata.CATEGORY]: + deviceMetadataFromConfig.category || this.config.deviceCategory || Conviva.Constants.DeviceCategory.WEB, + [Conviva.Constants.DeviceMetadata.BRAND]: deviceMetadataFromConfig.brand, + [Conviva.Constants.DeviceMetadata.MANUFACTURER]: deviceMetadataFromConfig.manufacturer, + [Conviva.Constants.DeviceMetadata.MODEL]: deviceMetadataFromConfig.model, + [Conviva.Constants.DeviceMetadata.TYPE]: deviceMetadataFromConfig.type, + [Conviva.Constants.DeviceMetadata.VERSION]: deviceMetadataFromConfig.version, + [Conviva.Constants.DeviceMetadata.OS_NAME]: deviceMetadataFromConfig.osName, + [Conviva.Constants.DeviceMetadata.OS_VERSION]: deviceMetadataFromConfig.osVersion, + }; + Conviva.Analytics.setDeviceMetadata(deviceMetadata); + + let callbackFunctions: Record = {}; + callbackFunctions[Conviva.Constants.CallbackFunctions.CONSOLE_LOG] = this.logger.consoleLog; + callbackFunctions[Conviva.Constants.CallbackFunctions.MAKE_REQUEST] = new Html5Http().makeRequest; + const html5Storage = new Html5Storage(); + callbackFunctions[Conviva.Constants.CallbackFunctions.SAVE_DATA] = html5Storage.saveData; + callbackFunctions[Conviva.Constants.CallbackFunctions.LOAD_DATA] = html5Storage.loadData; + callbackFunctions[Conviva.Constants.CallbackFunctions.CREATE_TIMER] = new Html5Timer().createTimer; + callbackFunctions[Conviva.Constants.CallbackFunctions.GET_EPOCH_TIME_IN_MS] = new Html5Time().getEpochTimeMs; + + const settings: Record = {}; + settings[Conviva.Constants.GATEWAY_URL] = config.gatewayUrl; + settings[Conviva.Constants.LOG_LEVEL] = this.config.debugLoggingEnabled + ? Conviva.Constants.LogLevel.DEBUG + : Conviva.Constants.LogLevel.NONE; + + Conviva.Analytics.init(customerKey, callbackFunctions, settings); + + this.contentMetadataBuilder = new ContentMetadataBuilder(this.logger); + + this.registerPlayerEvents(); + } + + public initializeSession(): void { + if (this.isSessionActive()) { + this.logger.consoleLog('[ ConvivaAnalyticsTracker ] There is already a session running.', Conviva.SystemSettings.LogLevel.WARNING); + return; + } + + // This could be called before source loaded. + // Without setting the asset name on the content metadata the SDK will throw errors when we initialize the session. + if (!this.player.getSource() && !this.contentMetadataBuilder.assetName) { + throw 'AssetName is missing. Load player source first or set assetName via updateContentMetadata'; + } + + this.internalInitializeSession(); + this.sessionEndedExternally = false; + } + + public endSession(): void { + if (!this.isSessionActive()) { + return; + } + + if (this._isAdBreakActive) { + this.debugLog('[ ConvivaAnalyticsTracker ] report ad skipped'); + this.convivaAdAnalytics.reportAdSkipped(); + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report playback ended state'); + this.convivaVideoAnalytics.reportPlaybackEnded(); + + this.internalEndSession(); + this.resetContentMetadata(); + this.sessionEndedExternally = true; + } + + public sendCustomApplicationEvent(eventName: string, eventAttributes: EventAttributes = {}): void { + if (!this.isSessionActive()) { + this.logger.consoleLog( + '[ ConvivaAnalyticsTracker ] cannot send application event, no active monitoring session', + Conviva.SystemSettings.LogLevel.WARNING, + ); + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report custom app event', { + eventName, + eventAttributes, + }); + // NOTE Conviva has event attribute capped and 256 bytes for custom events and will show up as a warning + // in monitoring session if greater than 256 bytes + this.convivaVideoAnalytics.reportAppEvent(eventName, eventAttributes); + } + + public sendCustomPlaybackEvent(eventName: string, eventAttributes: EventAttributes = {}): void { + if (!this.isSessionActive()) { + this.logger.consoleLog( + '[ ConvivaAnalyticsTracker ] cannot send playback event, no active monitoring session', + Conviva.SystemSettings.LogLevel.WARNING, + ); + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report custom playback event', { + eventName, + eventAttributes, + }); + // NOTE Conviva has event attribute capped and 256 bytes for custom events and will show up as a warning + // in monitoring session if greater than 256 bytes + this.convivaVideoAnalytics.reportPlaybackEvent(eventName, eventAttributes); + } + + public updateContentMetadata(metadataOverrides: Partial) { + this.internalUpdateContentMetadata(metadataOverrides); + } + + public reportPlaybackDeficiency( + message: string, + severity: Conviva.valueof, + endSession: boolean = true, + ) { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report playback failed', { + message, + }); + this.convivaVideoAnalytics.reportPlaybackFailed(message); + if (endSession) { + this.internalEndSession(); + this.resetContentMetadata(); + } + } + + public pauseTracking(): void { + this.debugLog('[ ConvivaAnalyticsTracker ] pause tracking via ad break started reporting'); + // AdStart is the right way to pause monitoring according to conviva. + this.convivaVideoAnalytics.reportAdBreakStarted( + Conviva.Constants.AdType.CLIENT_SIDE, + Conviva.Constants.AdPlayer.SEPARATE, + ); + } + + public resumeTracking(): void { + this.debugLog('[ ConvivaAnalyticsTracker ] resume tracking via ad break ended reporting'); + // AdEnd is the right way to resume monitoring according to conviva. + this.convivaVideoAnalytics.reportAdBreakEnded(); + } + + public release(event?: PlayerEventBase): void { + this.debugLog('[ ConvivaAnalyticsTracker ] releasing', event); + + this.unregisterPlayerEvents(); + this.internalEndSession(event); + + Conviva.Analytics.release(); + } + + private debugLog(message?: any, ...optionalParams: any[]): void { + if (this.config.debugLoggingEnabled) { + console.log.apply(console, arguments); + } + } + + private getUrlFromSource(source: SourceConfig): string { + switch (this.player.getStreamType()) { + case 'dash': + return source.dash; + case 'hls': + return source.hls; + case 'progressive': + if (Array.isArray(source.progressive)) { + // TODO check if the first stream can be another index (e.g. ordered by bitrate), and select the current + // startup url + return source.progressive[0].url; + } else { + return source.progressive; + } + } + } + + private internalUpdateContentMetadata(metadataOverrides: Partial) { + this.contentMetadataBuilder.setOverrides(metadataOverrides); + + if (!this.isSessionActive()) { + this.logger.consoleLog( + '[ ConvivaAnalyticsTracker ] no active session. Content metadata will be propagated to Conviva on session initialization.', + Conviva.SystemSettings.LogLevel.DEBUG, + ); + return; + } + + this.buildContentMetadata(); + this.updateSession(); + } + + /** + * A Conviva Session should only be initialized when there is a source provided in the player because + * Conviva only allows to update different `contentMetadata` only at different times. + * + * The session should be created as soon as there was a play intention from the user. + * + * Set only once: + * - assetName + * + * Update before first video frame: + * - viewerId + * - streamType + * - playerName + * - duration + * - custom + * + * Multiple updates during session: + * - streamUrl + * - defaultResource (unused) + * - encodedFrameRate (unused) + */ + private internalInitializeSession() { + this.debugLog('[ ConvivaAnalyticsTracker ] initializing session'); + + this.buildContentMetadata(); + + // Create a Conviva monitoring session. + this.convivaVideoAnalytics = Conviva.Analytics.buildVideoAnalytics(); + this.convivaAdAnalytics = Conviva.Analytics.buildAdAnalytics(this.convivaVideoAnalytics); + + const playerInfo = { + [Conviva.Constants.FRAMEWORK_NAME]: 'Bitmovin Player', + [Conviva.Constants.FRAMEWORK_VERSION]: this.player.version, + }; + + this.convivaVideoAnalytics.setPlayerInfo(playerInfo); + this.convivaAdAnalytics.setAdPlayerInfo(playerInfo); + + this.debugLog('[ ConvivaAnalyticsTracker ] report playback requested'); + this.convivaVideoAnalytics.reportPlaybackRequested(this.contentMetadataBuilder.build()); + + this.sessionKey = this.convivaVideoAnalytics.getSessionId(); + + this.debugLog('[ ConvivaAnalyticsTracker ] new session key', this.sessionKey); + + this.convivaVideoAnalytics.setCallback(() => { + const playheadTimeMs = this.player.getCurrentTime('relativetime' as TimeMode) * 1000; + + if (this._isAdBreakActive) { + this.debugLog('[ ConvivaAnalyticsTracker ] report ad player head time', playheadTimeMs); + this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.PLAY_HEAD_TIME, playheadTimeMs); + } else { + this.debugLog('[ ConvivaAnalyticsTracker ] report player head time', playheadTimeMs); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.PLAY_HEAD_TIME, playheadTimeMs); + } + }); + + if (!this.isSessionActive()) { + // Something went wrong. With stable system interfaces, this should never happen. + this.logger.consoleLog( + '[ ConvivaAnalyticsTracker ] Something went wrong, could not obtain session key', + Conviva.SystemSettings.LogLevel.ERROR, + ); + } + + // Send the session init audio language values. + this.trackUpdateAudioTrack(this.player.getAudio()); + + // Check if at session init has a subtitle enabled. + this.trackSubtitleWhenInternalInitialize(); + } + + /** + * Update contentMetadata which must be present before first video frame + */ + private buildContentMetadata() { + this.contentMetadataBuilder.duration = this.player.getDuration(); + this.contentMetadataBuilder.streamType = this.player.isLive() + ? Conviva.ContentMetadata.StreamType.LIVE + : Conviva.ContentMetadata.StreamType.VOD; + + this.contentMetadataBuilder.addToCustom({ + // Autoplay and preload are important options for the Video Startup Time so we track it as custom tags + [AUTOPLAY_CONTENT_METADATA_CUSTOM_TAG]: PlayerConfigHelper.getAutoplayConfig(this.player) + '', + [PRELOAD_CONTENT_METADATA_CUSTOM_TAG]: PlayerConfigHelper.getPreloadConfig(this.player) + '', + [INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG]: ConvivaAnalyticsTracker.VERSION, + }); + + const source = this.player.getSource(); + + // This could be called before we got a source + if (source) { + this.buildSourceRelatedMetadata(source); + } + } + + private buildSourceRelatedMetadata(source: SourceConfig) { + this.contentMetadataBuilder.assetName = this.getAssetNameFromSource(source); + this.contentMetadataBuilder.viewerId = this.contentMetadataBuilder.viewerId; + this.contentMetadataBuilder.addToCustom({ + [PLAYER_TYPE_CONTENT_METADATA_CUSTOM_TAG]: this.player.getPlayerType(), + [STREAM_TYPE_CONTENT_METADATA_CUSTOM_TAG]: this.player.getStreamType(), + [VR_CONTENT_TYPE_CONTENT_METADATA_CUSTOM_TAG]: source.vr && source.vr.contentType, + }); + + this.contentMetadataBuilder.streamUrl = this.getUrlFromSource(source); + } + + private updateSession() { + if (!this.isSessionActive()) { + return; + } + + this.convivaVideoAnalytics.setContentInfo(this.contentMetadataBuilder.build()); + } + + private getAssetNameFromSource(source: SourceConfig): string { + let assetName; + + const assetTitle = source.title; + if (assetTitle) { + assetName = assetTitle; + } else { + assetName = 'Untitled (no source.title set)'; + } + + return assetName; + } + + private internalEndSession = (event?: PlayerEventBase) => { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] end session', Conviva.Constants.NO_SESSION_KEY, event); + + this.convivaVideoAnalytics.release(); + this.convivaVideoAnalytics = null; + + this.convivaAdAnalytics.release(); + this.convivaAdAnalytics = null; + + this._isAdBreakActive = false; + }; + + private resetContentMetadata(): void { + this.contentMetadataBuilder.reset(); + } + + private isSessionActive(): boolean { + return !!this.convivaVideoAnalytics; + } + + private onSourceLoaded = (event: PlayerEventBase) => { + this.debugLog('[ ConvivaAnalyticsTracker ] [ Player Event ] source loaded', event); + + if (!this.isSessionActive()) { + return; + } + + this.buildSourceRelatedMetadata(this.player.getSource()); + this.updateSession(); + }; + + public trackPlaybackStateChanged(event: PlayerEventBase) { + if (!this.isSessionActive()) { + return; + } + + const playerState = PlayerStateHelper.getPlayerStateFromEvent(event, this.events, this.player); + const stallTrackingStartEvents = [ + this.events.Play, + this.events.Seek, + this.events.TimeShift, + ]; + const stallTrackingClearEvents = [ + this.events.StallStarted, + this.events.Playing, + this.events.Paused, + this.events.Seeked, + this.events.TimeShifted, + this.events.StallEnded, + this.events.PlaybackFinished, + ]; + + if (stallTrackingStartEvents.indexOf(event.type) !== -1) { + this.stallTrackingTimeout.start(); + } else if (stallTrackingClearEvents.indexOf(event.type) !== -1) { + this.stallTrackingTimeout.clear(); + } + + + if (playerState) { + if (this._isAdBreakActive) { + this.debugLog('[ ConvivaAnalyticsTracker ] report ad playback state', playerState); + this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.PLAYER_STATE, playerState); + } else { + this.debugLog('[ ConvivaAnalyticsTracker ] report playback state', playerState); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.PLAYER_STATE, playerState); + } + } + + if (event.type === this.events.PlaybackFinished) { + this.debugLog('[ ConvivaAnalyticsTracker ] report playback ended'); + this.convivaVideoAnalytics.reportPlaybackEnded(); + } + } + + private onPlay = (event: PlaybackEvent) => { + this.debugLog('[ ConvivaAnalyticsTracker ] [ Player Event ] play'); + + if (!this.canTrackPlayEvent) { + return; + } + + // In case the playback has finished and the user replays the stream create a new session + if (!this.isSessionActive() && !this.sessionEndedExternally) { + this.internalInitializeSession(); + } + }; + + private onPlaying = (event: PlaybackEvent) => { + this.debugLog('[ ConvivaAnalyticsTracker ] [ Player Event ] playing', event); + + if (!this.isSessionActive()) { + return; + } + + this.contentMetadataBuilder.setPlaybackStarted(true); + this.updateSession(); + }; + + private onPlaybackFinished = (event: PlayerEventBase) => { + this.debugLog('[ ConvivaAnalyticsTracker ] [ Player Event ] playback finished', event); + + if (!this.isSessionActive()) { + return; + } + + this.convivaVideoAnalytics.release(); + this.convivaVideoAnalytics = null; + + this.convivaAdAnalytics.release(); + this.convivaAdAnalytics = null; + }; + + public trackVideoQualityChanged = (event: VideoQualityChangedEvent) => { + if (!this.isSessionActive()) { + return; + } + + // We calculate the bitrate with a divisor of 1000 so the values look nicer + // Example: 250000 / 1000 => 250 kbps (250000 / 1024 => 244kbps) + const bitrateKbps = Math.round(event.targetQuality.bitrate / 1000); + + this.debugLog('[ ConvivaAnalyticsTracker ] report bitrate', { + event, + bitrateKbps, + }); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.BITRATE, bitrateKbps); + }; + + public trackAdBreakStarted = (type: Conviva.valueof) => { + if (!this.isSessionActive()) { + return; + } + + this._isAdBreakActive = true; + + this.debugLog('[ ConvivaAnalyticsTracker ] report ad break started', { type }); + this.convivaVideoAnalytics.reportAdBreakStarted( + type, + type === Conviva.Constants.AdType.CLIENT_SIDE ? Conviva.Constants.AdPlayer.SEPARATE : Conviva.Constants.AdPlayer.CONTENT, + ); + }; + + public trackAdStarted = (adInfo: Conviva.ConvivaMetadata, type: Conviva.valueof, bitrateKbps?: number) => { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report ad started', { + adInfo, + type, + bitrateKbps, + }); + this.convivaAdAnalytics.reportAdStarted(adInfo); + + this.debugLog(`[ ConvivaAnalyticsTracker ] report ${PlayerStateHelper.getPlayerState(this.player)} ad playback state`); + this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.PLAYER_STATE, PlayerStateHelper.getPlayerState(this.player)); + + if (type === Conviva.Constants.AdType.SERVER_SIDE) { + const playbackVideoData = this.player.getPlaybackVideoData(); + const resolution = `${playbackVideoData.width}x${playbackVideoData.height}`; + + this.debugLog('[ ConvivaAnalyticsTracker ] report ad resolution', resolution); + this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.RESOLUTION, resolution); + + if (playbackVideoData.frameRate) { + this.debugLog('[ ConvivaAnalyticsTracker ] report framerate', playbackVideoData.frameRate); + this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.RENDERED_FRAMERATE, playbackVideoData.frameRate); + } + } + + if (bitrateKbps) { + this.debugLog('[ ConvivaAnalyticsTracker ] report ad bitrate', bitrateKbps); + this.convivaAdAnalytics.reportAdMetric(Conviva.Constants.Playback.BITRATE, bitrateKbps); + } + } + + public trackAdFinished = () => { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report ad ended'); + this.convivaAdAnalytics.reportAdEnded(); + } + + public trackAdSkipped = () => { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report ad skipped'); + this.convivaAdAnalytics.reportAdSkipped(); + }; + + public trackAdBreakFinished = () => { + if (!this.isSessionActive()) { + return; + } + + this._isAdBreakActive = false; + + this.debugLog('[ ConvivaAnalyticsTracker ] report ad break ended'); + this.convivaVideoAnalytics.reportAdBreakEnded(); + + this.debugLog(`[ ConvivaAnalyticsTracker ] report ${PlayerStateHelper.getPlayerState(this.player)} playback state`); + this.convivaVideoAnalytics.reportPlaybackMetric( + Conviva.Constants.Playback.PLAYER_STATE, + PlayerStateHelper.getPlayerState(this.player), + ); + }; + + public trackAdError = (event: ErrorEvent) => { + if (!this.isSessionActive()) { + return; + } + + const formattedError = AdHelper.formatCsaiAdError(event); + + this.debugLog('[ ConvivaAnalyticsTracker ] report ad error', { + event, + formattedError, + }); + this.convivaAdAnalytics.reportAdError(formattedError, Conviva.Constants.ErrorSeverity.WARNING); + }; + + public trackSeekStart(target: number) { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report seek started'); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SEEK_STARTED, target); + } + + public trackSeekEnd() { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report seek ended'); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SEEK_ENDED); + } + + public trackUpdateAudioTrack(audioTrack: AudioTrack) { + if (!this.isSessionActive()) { + return; + } + + const formattedAudio = + audioTrack.lang !== 'unknown' ? '[' + audioTrack.lang + ']:' + audioTrack.label : audioTrack.label; + + this.debugLog('[ ConvivaAnalyticsTracker ] report audio language', { + formattedAudio, + }); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.AUDIO_LANGUAGE, formattedAudio); + } + + public trackUpdateSubtitleTrack(subtitleTrack: SubtitleTrack) { + if (!this.isSessionActive()) { + return; + } + + const formattedSubtitle = + subtitleTrack.lang !== 'unknown' ? '[' + subtitleTrack.lang + ']:' + subtitleTrack.label : subtitleTrack.label; + + if (subtitleTrack.kind === 'subtitles') { + this.debugLog('[ ConvivaAnalyticsTracker ] report subtitles language', { + formattedSubtitle, + }); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SUBTITLES_LANGUAGE, formattedSubtitle); + + this.debugLog('[ ConvivaAnalyticsTracker ] report off closed captions language'); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.CLOSED_CAPTIONS_LANGUAGE, 'off'); + } else if (subtitleTrack.kind === 'captions') { + this.debugLog('[ ConvivaAnalyticsTracker ] report closed captions language', { + formattedSubtitle, + }); + this.convivaVideoAnalytics.reportPlaybackMetric( + Conviva.Constants.Playback.CLOSED_CAPTIONS_LANGUAGE, + formattedSubtitle, + ); + + this.debugLog('[ ConvivaAnalyticsTracker ] report off subtitles language'); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SUBTITLES_LANGUAGE, 'off'); + } else { + this.trackTurnOffSubtitles(); + } + } + + private trackSubtitleWhenInternalInitialize() { + if (!this.isSessionActive()) { + return; + } + + if (this.player.subtitles !== undefined) { + const enableSubtitle = this.player.subtitles.list().filter((i) => i.enabled); + + // Send the session init subtitle language values. + if (enableSubtitle.length === 1) { + this.trackUpdateSubtitleTrack(enableSubtitle[0]); + return; + } + } + + this.trackTurnOffSubtitles(); + } + + public trackTurnOffSubtitles() { + if (!this.isSessionActive()) { + return; + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report off subtitles language'); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.SUBTITLES_LANGUAGE, 'off'); + + this.debugLog('[ ConvivaAnalyticsTracker ] report off closed captions language'); + this.convivaVideoAnalytics.reportPlaybackMetric(Conviva.Constants.Playback.CLOSED_CAPTIONS_LANGUAGE, 'off'); + } + + public trackError = (event: ErrorEvent) => { + if (!this.isSessionActive() && !this.sessionEndedExternally) { + // initialize Session if not yet initialized to capture Video Start Failures + this.internalInitializeSession(); + } + + this.debugLog('[ ConvivaAnalyticsTracker ] report playback deficiency', event); + this.reportPlaybackDeficiency(String(event.code) + ' ' + event.name, Conviva.Constants.ErrorSeverity.FATAL); + }; + + private onSourceUnloaded = (event: PlayerEventBase) => { + this.debugLog('[ ConvivaAnalyticsTracker ] [ Player Event ] source unloaded', event); + + if (this._isAdBreakActive) { + // Ignore sourceUnloaded events during ads + return; + } else { + this.internalEndSession(event); + this.resetContentMetadata(); + } + }; + + private onDestroy = (event: any) => { + this.debugLog('[ ConvivaAnalyticsTracker ] [ Player Event ] destroy', event); + + this.release(event); + }; + + private registerPlayerEvents(): void { + this.handlers.add(this.events.SourceLoaded, this.onSourceLoaded); + this.handlers.add(this.events.Play, this.onPlay); + this.handlers.add(this.events.Playing, this.onPlaying); + this.handlers.add(this.events.PlaybackFinished, this.onPlaybackFinished); + this.handlers.add(this.events.SourceUnloaded, this.onSourceUnloaded); + this.handlers.add(this.events.Destroy, this.onDestroy); + } + + private unregisterPlayerEvents(): void { + this.handlers.clear(); + } + + static get version(): string { + return ConvivaAnalyticsTracker.VERSION; + } +} diff --git a/src/ts/helper/AdHelper.ts b/src/ts/helper/AdHelper.ts index 1ab71bd..37f2f03 100644 --- a/src/ts/helper/AdHelper.ts +++ b/src/ts/helper/AdHelper.ts @@ -1,8 +1,47 @@ import { Ad, AdBreak, AdBreakEvent, AdData, AdEvent, ErrorEvent, LinearAd, PlayerAPI, VastAdData } from 'bitmovin-player'; import * as Conviva from '@convivainc/conviva-js-coresdk'; +import { INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG, STREAM_TYPE_CONTENT_METADATA_CUSTOM_TAG } from '../ConvivaAnalyticsTracker'; + +export interface SsaiAdInfo { + /** + * The ad ID extracted from the ad server that contains the ad creative. + */ + id: string; + /** + * The title of the ad. + */ + title?: string; + /** + * Duration of the ad, in seconds. + */ + duration?: number; + /** + * The name of the ad system (i.e., the ad server). + */ + adSystem?: string; + /** + * The position of the ad. + */ + position?: Conviva.valueof; + /** + * Indicates whether this ad is a slate or not. Set to true for slate and false for a regular ad. + */ + isSlate?: boolean; + /** + * The name of the ad stitcher. + */ + adStitcher?: string; + /** + * Additional ad metadata. This is a map of key-value pairs that can be used to pass additional metadata about the ad. + * A list of ad metadata can be found here: Conviva documentation + *

+ * Metadata provided here will supersede any data provided in the ad break info. + */ + additionalMetadata?: Record; +} export class AdHelper { - public static mapAdPosition( + public static mapCsaiAdPosition( adBreak: AdBreak, player: PlayerAPI, ): Conviva.valueof { @@ -17,7 +56,7 @@ export class AdHelper { return Conviva.Constants.AdPosition.MIDROLL; } - public static formatAdErrorEvent(event: ErrorEvent & { + public static formatCsaiAdError(event: ErrorEvent & { data?: { code?: number, }, @@ -37,25 +76,15 @@ export class AdHelper { return formattedErrorParts.join(' '); } - public static extractConvivaAdInfo(player: PlayerAPI, adBreakEvent: AdBreakEvent, adEvent: AdEvent): Conviva.ConvivaMetadata { - const adPosition = AdHelper.mapAdPosition(adBreakEvent.adBreak, player); + public static extractCsaiConvivaAdInfo(player: PlayerAPI, adBreakEvent: AdBreakEvent, adEvent: AdEvent): Conviva.ConvivaMetadata { const ad = adEvent.ad as Ad | LinearAd; const adData = ad.data as undefined | AdData | VastAdData; let adSystemName = 'NA'; let creativeId = 'NA'; - let adTitle: string | undefined; + let adTitle = 'NA'; let firstAdId = ad.id; - // TODO these two are not exposed currently. Add them whenever the player - // exposes them similar to https://github.com/bitmovin-engineering/player-android/pull/3147. - // Related discussion https://bitmovin.slack.com/archives/C0LJ16JBS/p1716801796326889. - let firstAdSystem = 'NA'; - let firstCreativeId = 'NA'; - - // TODO This is not exposed currently. Add it whenever the player - // exposes it. Related discussion https://bitmovin.slack.com/archives/C0LJ16JBS/p1716801970037469. - let mediaFileApiFramework = 'NA'; if (adData) { if ('adSystem' in adData && adData.adSystem?.name) { @@ -78,29 +107,76 @@ export class AdHelper { const adInfo: Conviva.ConvivaMetadata = { 'c3.ad.id': ad.id, 'c3.ad.technology': Conviva.Constants.AdType.CLIENT_SIDE, - 'c3.ad.position': adPosition, + 'c3.ad.position': AdHelper.mapCsaiAdPosition(adBreakEvent.adBreak, player), 'c3.ad.system': adSystemName, 'c3.ad.creativeId': creativeId, 'c3.ad.firstAdId': firstAdId, - 'c3.ad.mediaFileApiFramework': mediaFileApiFramework, - 'c3.ad.firstAdSystem': firstAdSystem, - 'c3.ad.firstCreativeId': firstCreativeId, + [Conviva.Constants.ASSET_NAME]: adTitle, + [Conviva.Constants.STREAM_URL]: ad.mediaFileUrl || 'NA', + + // TODO This is not exposed currently. Add it whenever the player + // exposes it. Related discussion https://bitmovin.slack.com/archives/C0LJ16JBS/p1716801970037469. + 'c3.ad.mediaFileApiFramework': 'NA', + + // TODO these two are not exposed currently. Add them whenever the player + // exposes them similar to https://github.com/bitmovin-engineering/player-android/pull/3147. + // Related discussion https://bitmovin.slack.com/archives/C0LJ16JBS/p1716801796326889. + 'c3.ad.firstAdSystem': 'NA', + 'c3.ad.firstCreativeId': 'NA', + - // These two are not relevant for the client side (keep in the code for documentation purposes) + // These are not relevant for the client side (keep in the code for documentation purposes) // 'c3.ad.adStitcher': undefined, // 'c3.ad.isSlate': undefined, }; - if (adTitle) { - adInfo[Conviva.Constants.ASSET_NAME] = adTitle; + if ('duration' in ad && ad.duration) { + adInfo[Conviva.Constants.DURATION] = ad.duration; } - if (ad.mediaFileUrl) { - adInfo[Conviva.Constants.STREAM_URL] = ad.mediaFileUrl; - } + return adInfo; + } - if ('duration' in ad && ad.duration) { - adInfo[Conviva.Constants.DURATION] = ad.duration; + public static convertSsaiAdInfoToConvivaAdInfo(ssaiAdInfo: SsaiAdInfo, allCurrentContentMetadata: Conviva.ConvivaMetadata): Conviva.ConvivaMetadata { + const keysToPick = [ + INTEGRATION_VERSION_CONTENT_METADATA_CUSTOM_TAG, + STREAM_TYPE_CONTENT_METADATA_CUSTOM_TAG, + Conviva.Constants.ASSET_NAME, + Conviva.Constants.IS_LIVE, + Conviva.Constants.DEFAULT_RESOURCE, + Conviva.Constants.ENCODED_FRAMERATE, + Conviva.Constants.VIEWER_ID, + Conviva.Constants.PLAYER_NAME, + ]; + const selectedCurrentContentMetadata: Record = {}; + + keysToPick.forEach(key => { + selectedCurrentContentMetadata[key] = allCurrentContentMetadata[key]; + }); + + + const adInfo: Conviva.ConvivaMetadata = { + ...selectedCurrentContentMetadata, + ...ssaiAdInfo.additionalMetadata, + 'c3.ad.id': ssaiAdInfo.id, + 'c3.ad.technology': Conviva.Constants.AdType.SERVER_SIDE, + 'c3.ad.position': ssaiAdInfo.position || 'NA', + 'c3.ad.system': ssaiAdInfo.adSystem || 'NA', + [Conviva.Constants.ASSET_NAME]: ssaiAdInfo.title || selectedCurrentContentMetadata[Conviva.Constants.ASSET_NAME] || 'NA', + 'c3.ad.adStitcher': ssaiAdInfo.adStitcher || 'NA', + 'c3.ad.isSlate': ssaiAdInfo.isSlate === undefined ? 'NA' : ssaiAdInfo.isSlate.toString(), + + // These are not relevant for the server side (keep in the code for documentation purposes) + // 'c3.ad.creativeId': undefined, + // 'c3.ad.firstAdId': undefined, + // [Conviva.Constants.STREAM_URL]: undefined, + // 'c3.ad.firstAdSystem': undefined, + // 'c3.ad.firstCreativeId': undefined, + // 'c3.ad.mediaFileApiFramework': undefined + }; + + if (ssaiAdInfo.duration) { + adInfo[Conviva.Constants.DURATION] = ssaiAdInfo.duration; } return adInfo; diff --git a/src/ts/helper/ObjectUtils.ts b/src/ts/helper/ObjectUtils.ts index 715e6a5..8386dcd 100644 --- a/src/ts/helper/ObjectUtils.ts +++ b/src/ts/helper/ObjectUtils.ts @@ -1,4 +1,4 @@ -import { EventAttributes } from '../ConvivaAnalytics'; +import { EventAttributes } from '../ConvivaAnalyticsTracker'; export namespace ObjectUtils { export function flatten(object: any, prefix: string = '') { diff --git a/src/ts/helper/PlayerConfigHelper.ts b/src/ts/helper/PlayerConfigHelper.ts new file mode 100644 index 0000000..bcdb5b1 --- /dev/null +++ b/src/ts/helper/PlayerConfigHelper.ts @@ -0,0 +1,62 @@ +import { PlayerAPI } from 'bitmovin-player'; +import { BrowserUtils } from './BrowserUtils'; + +export class PlayerConfigHelper { + /** + * The config for autoplay and preload have great impact to the VST (Video Startup Time) we track it. + * Since there is no way to get default config values from the player they are hardcoded. + */ + public static AUTOPLAY_DEFAULT_CONFIG: boolean = false; + + /** + * Extract autoplay config form player + * + * @param player: Player + */ + public static getAutoplayConfig(player: PlayerAPI): boolean { + const playerConfig = player.getConfig(); + + if (playerConfig.playback && playerConfig.playback.autoplay !== undefined) { + return playerConfig.playback.autoplay; + } else { + return PlayerConfigHelper.AUTOPLAY_DEFAULT_CONFIG; + } + } + + /** + * Extract preload config from player + * + * The preload config can be set individual for mobile or desktop as well as on root level for both platforms. + * Default value is true for VOD and false for live streams. If the value is not set for current platform or on root + * level the default value will be used over the value for the other platform. + * + * @param player: Player + */ + public static getPreloadConfig(player: PlayerAPI): boolean { + const playerConfig = player.getConfig(); + + if (BrowserUtils.isMobile()) { + if ( + playerConfig.adaptation && + playerConfig.adaptation.mobile && + playerConfig.adaptation.mobile.preload !== undefined + ) { + return playerConfig.adaptation.mobile.preload; + } + } else { + if ( + playerConfig.adaptation && + playerConfig.adaptation.desktop && + playerConfig.adaptation.desktop.preload !== undefined + ) { + return playerConfig.adaptation.desktop.preload; + } + } + + if (playerConfig.adaptation && playerConfig.adaptation.preload !== undefined) { + return playerConfig.adaptation.preload; + } + + return !player.isLive(); + } +} diff --git a/src/ts/helper/PlayerEventWrapper.ts b/src/ts/helper/PlayerEventWrapper.ts new file mode 100644 index 0000000..032e39a --- /dev/null +++ b/src/ts/helper/PlayerEventWrapper.ts @@ -0,0 +1,38 @@ +import { PlayerAPI, PlayerEvent, PlayerEventBase } from 'bitmovin-player'; +import { ArrayUtils } from 'bitmovin-player-ui/dist/js/framework/arrayutils'; + +export class PlayerEventWrapper { + private player: PlayerAPI; + private readonly eventHandlers: { [eventType: string]: Array<(event?: PlayerEventBase) => void> }; + + constructor(player: PlayerAPI) { + this.player = player; + this.eventHandlers = {}; + } + + public add(eventType: PlayerEvent, callback: (event?: PlayerEventBase) => void): void { + this.player.on(eventType, callback); + + if (!this.eventHandlers[eventType]) { + this.eventHandlers[eventType] = []; + } + + this.eventHandlers[eventType].push(callback); + } + + public remove(eventType: PlayerEvent, callback: (event?: PlayerEventBase) => void): void { + this.player.off(eventType, callback); + + if (this.eventHandlers[eventType]) { + ArrayUtils.remove(this.eventHandlers[eventType], callback); + } + } + + public clear(): void { + for (const eventType in this.eventHandlers) { + for (const callback of this.eventHandlers[eventType]) { + this.remove(eventType as PlayerEvent, callback); + } + } + } +} diff --git a/src/ts/helper/PlayerStateHelper.ts b/src/ts/helper/PlayerStateHelper.ts new file mode 100644 index 0000000..ed0c15e --- /dev/null +++ b/src/ts/helper/PlayerStateHelper.ts @@ -0,0 +1,43 @@ +import { PlayerAPI, PlayerEvent, PlayerEventBase } from 'bitmovin-player'; +import * as Conviva from '@convivainc/conviva-js-coresdk'; + +export class PlayerStateHelper { + public static getPlayerStateFromEvent(event: PlayerEventBase, events: typeof PlayerEvent, player: PlayerAPI) { + let playerState; + + switch (event.type) { + case events.StallStarted: + playerState = Conviva.Constants.PlayerState.BUFFERING; + break; + case events.Playing: + playerState = Conviva.Constants.PlayerState.PLAYING; + break; + case events.Paused: + playerState = Conviva.Constants.PlayerState.PAUSED; + break; + case events.Seeked: + case events.TimeShifted: + case events.StallEnded: + if (player.isPlaying()) { + playerState = Conviva.Constants.PlayerState.PLAYING; + } else { + playerState = Conviva.Constants.PlayerState.PAUSED; + } + break; + } + + return playerState; + } + + public static getPlayerState(player: PlayerAPI): Conviva.valueof { + if (player.isStalled()) { + return Conviva.Constants.PlayerState.BUFFERING; + } + + if (player.isPlaying()) { + return Conviva.Constants.PlayerState.PLAYING; + } + + return Conviva.Constants.PlayerState.PAUSED; + } +} diff --git a/src/ts/index.ts b/src/ts/index.ts index a671bcb..27a030a 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -1,5 +1,7 @@ // Import to extend Conviva types. import './conviva/ConvivaExtension'; -export { ConvivaAnalytics, ConvivaAnalyticsConfiguration, EventAttributes } from './ConvivaAnalytics'; +export { ConvivaAnalyticsConfiguration, EventAttributes } from './ConvivaAnalyticsTracker'; +export { ConvivaAnalytics } from './ConvivaAnalytics'; export { Metadata } from './ContentMetadataBuilder'; +export { SsaiAdInfo } from './helper/AdHelper';