diff --git a/README.md b/README.md index 2e6c094c..f622252f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ the official THEOplayer React Native video player. | Mux analytics | [![%40theoplayer/react-native-analytics-mux](https://img.shields.io/npm/v/%40theoplayer%2Freact-native-analytics-mux?label=%40theoplayer/react-native-analytics-mux)](https://www.npmjs.com/package/%40theoplayer%2Freact-native-analytics-mux) | [`Mux`](https://github.com/THEOplayer/react-native-connectors/tree/main/mux) | | Nielsen analytics | [![%40theoplayer/react-native-analytics-nielsen](https://img.shields.io/npm/v/%40theoplayer%2Freact-native-analytics-nielsen?label=%40theoplayer/react-native-analytics-nielsen)](https://www.npmjs.com/package/%40theoplayer%2Freact-native-analytics-nielsen) | [`Nielsen`](https://github.com/THEOplayer/react-native-connectors/tree/main/nielsen) | | Nielsen AdScript analytics | [![%40theoplayer/react-native-analytics-adscript](https://img.shields.io/npm/v/%40theoplayer%2Freact-native-analytics-adscript?label=%40theoplayer/react-native-analytics-adscript)](https://www.npmjs.com/package/%40theoplayer%2Freact-native-analytics-adscript) | [`AdScript`](https://github.com/THEOplayer/react-native-connectors/tree/main/adscript) | +| NPAW (YOUBORA) analytics (NPAW v7+) | [![%40theoplayer/react-native-analytics-npaw](https://img.shields.io/npm/v/%40theoplayer%2Freact-native-analytics-npaw?label=%40theoplayer/react-native-analytics-npaw)](https://www.npmjs.com/package/%40theoplayer%2Freact-native-analytics-npaw) | [`NPAW`](https://github.com/THEOplayer/react-native-connectors/tree/main/npaw) | | Yospace SSAI | [![%40theoplayer/react-native-yospace](https://img.shields.io/npm/v/%40theoplayer%2Freact-native-yospace?label=%40theoplayer/react-native-yospace=)](https://www.npmjs.com/package/%40theoplayer%2Freact-native-yospace) | [`Yospace`](https://github.com/THEOplayer/react-native-connectors/tree/main/yospace) | | Youbora analytics | [![%40theoplayer/react-native-analytics-youbora](https://img.shields.io/npm/v/%40theoplayer%2Freact-native-analytics-youbora?label=%40theoplayer/react-native-analytics-youbora=)](https://www.npmjs.com/package/%40theoplayer%2Freact-native-analytics-youbora) | [`Youbora`](https://github.com/THEOplayer/react-native-connectors/tree/main/youbora) | diff --git a/apps/e2e/.env.example b/apps/e2e/.env.example index 73a74bd0..9a505bc2 100644 --- a/apps/e2e/.env.example +++ b/apps/e2e/.env.example @@ -6,6 +6,9 @@ CONVIVA_DEBUG=true CONVIVA_TOUCHSTONE_SERVICE_URL= CONVIVA_VIEWER_ID=e2e_viewer_id +# NPAW +NPAW_ACCOUNT_CODE= + # BITMOVIN BITMOVIN_LICENSE_KEY= BITMOVIN_LOG_LEVEL=DEBUG diff --git a/apps/e2e/metro.config.js b/apps/e2e/metro.config.js index d2cf485f..fd995762 100644 --- a/apps/e2e/metro.config.js +++ b/apps/e2e/metro.config.js @@ -11,9 +11,7 @@ function escapeRegExp(str) { // Block these packages from being resolved out of root/node_modules. // They are already explicitly mapped to the app-local copies via extraNodeModules. -const rootNodeModulesBlockList = packages.map( - (pkg) => new RegExp(`^${escapeRegExp(path.join(root, 'node_modules', pkg))}(/.*)?$`) -); +const rootNodeModulesBlockList = packages.map((pkg) => new RegExp(`^${escapeRegExp(path.join(root, 'node_modules', pkg))}(/.*)?$`)); const connectors = [ 'adobe', @@ -27,6 +25,7 @@ const connectors = [ 'gemius', 'mux', 'nielsen', + 'npaw', 'yospace', 'youbora', ]; diff --git a/apps/e2e/src/App.tsx b/apps/e2e/src/App.tsx index ae5c9dd7..cbbbe97c 100644 --- a/apps/e2e/src/App.tsx +++ b/apps/e2e/src/App.tsx @@ -39,6 +39,7 @@ import { ConnectorSubMenu } from './components/menu/ConnectorSubMenu'; import { ConnectorsMenuButton } from './components/menu/ConnectorMenu'; import { useAdobeEdgeConnector } from './connectors/adobe-edge'; import { muxOptions, useMuxConnector } from './connectors/mux'; +import { useNpawConnector } from './connectors/npaw'; const playerConfig: PlayerConfiguration = { // Get your THEOplayer license from https://portal.theoplayer.com/ @@ -63,6 +64,7 @@ export default function App() { const { bitmovin, initBitmovin, onProgramChange: onBitmovinProgramChange, onUpdateCustomData: onBitmovinUpdateCustomData } = useBitmovinConnector(); const { adobeEdge, initAdobeEdge } = useAdobeEdgeConnector(); const { initMux } = useMuxConnector(); + const { initNpaw } = useNpawConnector(); const onSourceChange = () => { console.log('onSourceChange'); @@ -70,7 +72,7 @@ export default function App() { key1: 'value1', key2: 'value2', }); - } + }; const onPlayerReady = (player: THEOplayer) => { setPlayer(player); @@ -79,6 +81,7 @@ export default function App() { initBitmovin(player, bitmovinDefaultMetadata); initConviva(player); initMux(player, muxOptions); + initNpaw(player); // optional debug logs player.addEventListener(PlayerEventType.SOURCE_CHANGE, onSourceChange); diff --git a/apps/e2e/src/connectors/npaw.ts b/apps/e2e/src/connectors/npaw.ts new file mode 100644 index 00000000..2cb3eb05 --- /dev/null +++ b/apps/e2e/src/connectors/npaw.ts @@ -0,0 +1,20 @@ +import { LogLevel, NpawConnectorConfig, useNpaw } from '@theoplayer/react-native-analytics-npaw'; +import Config from 'react-native-config'; + +export const npawConfig: NpawConnectorConfig = { + accountCode: Config.NPAW_ACCOUNT_CODE, + analytics: { + 'content.title': 'THEOplayer E2E', + 'content.contentId': 'e2e-content', + 'content.metadata': { + customCategory: 'demo', + customEnvironment: 'e2e', + }, + }, + logLevel: LogLevel.DEBUG, +}; + +export function useNpawConnector() { + const [, initNpaw] = useNpaw(npawConfig); + return { initNpaw }; +} diff --git a/apps/e2e/src/tests/Npaw.spec.ts b/apps/e2e/src/tests/Npaw.spec.ts new file mode 100644 index 00000000..23c95a0d --- /dev/null +++ b/apps/e2e/src/tests/Npaw.spec.ts @@ -0,0 +1,22 @@ +import { TestScope } from 'cavy'; +import { LogLevel, NpawConnector } from '@theoplayer/react-native-analytics-npaw'; +import { THEOplayer } from 'react-native-theoplayer'; +import { testConnector } from './ConnectorUtils'; + +export default function (spec: TestScope) { + spec.describe(`Setup NPAW connector`, function () { + let connector: NpawConnector; + testConnector( + spec, + (player: THEOplayer) => { + connector = new NpawConnector(player, { accountCode: 'testAccountCode' }); + }, + () => { + connector.setLogLevel(LogLevel.DEBUG); + }, + () => { + connector.destroy(); + }, + ); + }); +} diff --git a/apps/e2e/src/tests/index.ts b/apps/e2e/src/tests/index.ts index 919e4cbf..ec5f9f38 100644 --- a/apps/e2e/src/tests/index.ts +++ b/apps/e2e/src/tests/index.ts @@ -5,7 +5,8 @@ import Bitmovin from './Bitmovin.spec'; import Comscore from './Comscore.spec'; import Conviva from './Conviva.spec'; import Nielsen from './Nielsen.spec'; +import Npaw from './Npaw.spec'; -const tests = [Adobe, AdobeNative, AdobeEdge, Bitmovin, Comscore, Conviva, Nielsen]; +const tests = [Adobe, AdobeNative, AdobeEdge, Bitmovin, Comscore, Conviva, Nielsen, Npaw]; export default tests; diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json index 8d6cb506..1e97de64 100644 --- a/apps/e2e/tsconfig.json +++ b/apps/e2e/tsconfig.json @@ -10,6 +10,7 @@ "@theoplayer/react-native-analytics-conviva": ["../../conviva/src/index"], "@theoplayer/react-native-analytics-mux": ["../../mux/src/index"], "@theoplayer/react-native-analytics-nielsen": ["../../nielsen/src/index"], + "@theoplayer/react-native-analytics-npaw": ["../../npaw/src/index"], "@theoplayer/react-native-yospace": ["../../yospace/src/index"], "@theoplayer/react-native-adscript": ["../../adscript/src/index"], "@theoplayer/react-native-gemius": ["../../gemius/src/index"] diff --git a/npaw/.gitignore b/npaw/.gitignore new file mode 100644 index 00000000..75356714 --- /dev/null +++ b/npaw/.gitignore @@ -0,0 +1,70 @@ +# OSX +# +.DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/ +jsconfig.json + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml + +# Cocoapods +# +example/ios/Pods + +# Ruby +example/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore + +# Expo +.expo/ + +# Turborepo +.turbo/ + +# generated by bob +lib/ diff --git a/npaw/CHANGELOG.md b/npaw/CHANGELOG.md new file mode 100644 index 00000000..6602719e --- /dev/null +++ b/npaw/CHANGELOG.md @@ -0,0 +1,5 @@ +# @theoplayer/react-native-analytics-npaw + +## 1.0.0 + +- Initial release. diff --git a/npaw/LICENSE b/npaw/LICENSE new file mode 100644 index 00000000..ed130fc5 --- /dev/null +++ b/npaw/LICENSE @@ -0,0 +1,33 @@ +The Clear BSD License + +Copyright (c) 2025 Dolby International AB +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted (subject to the limitations in the disclaimer +below) provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED +BY THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/npaw/README.md b/npaw/README.md new file mode 100644 index 00000000..296a47e4 --- /dev/null +++ b/npaw/README.md @@ -0,0 +1,95 @@ +# THEOplayer React Native NPAW Connector + +An [NPAW](https://npaw.com/) (formerly YOUBORA) analytics connector for `@theoplayer/react-native`. + +This connector is JavaScript-only and works across iOS, Android and web through the unified +`react-native-theoplayer` player, using only the `npaw-plugin-react-native` plugin — no native +iOS/Android modules are required. + +> **Note:** This connector targets **NPAW v7+** (it depends on `npaw-plugin-react-native` `^7.3.x`). + +## Installation + +```sh +npm install @theoplayer/react-native-analytics-npaw +``` + +## Usage + +### Configuring the connector + +Create the connector with the `useNpaw` hook, passing a config object and initializing it once the +`THEOplayer` instance is ready: + +```tsx +import { LogLevel, useNpaw } from '@theoplayer/react-native-analytics-npaw'; + +const App = () => { + const [npaw, initNpaw] = useNpaw({ + accountCode: 'your-account-code', + analytics: { + 'content.title': 'Big Buck Bunny', + 'content.isLive': false, + // Free-form custom metadata shown in the NPAW "Metadata" panel: + 'content.metadata': { genre: 'animation' }, + }, + // Optional NpawPlugin options (host, sessionRecovery, ...): + plugin: { sessionRecovery: 'auto' }, + logLevel: LogLevel.DEBUG, + }); + + const onPlayerReady = (player: THEOplayer) => { + initNpaw(player); + }; + + return ; +}; +``` + +Alternatively, construct the `NpawConnector` class directly: + +```ts +import { NpawConnector } from '@theoplayer/react-native-analytics-npaw'; + +const connector = new NpawConnector(player, { accountCode: 'your-account-code' }); +``` + +### Configuration + +| Field | Description | +|---------------|------------------------------------------------------------------------------------| +| `accountCode` | Your NPAW account code. **Required.** | +| `analytics` | Per-video analytics options, e.g. `content.title`, `content.isLive`, `content.metadata`, `user.name`, ... | +| `plugin` | `NpawPlugin` options such as `host`, `sessionRecovery` and `balancerAnalyticsEnabled`. | +| `logLevel` | Initial `LogLevel`. | + +### What is tracked automatically + +Once initialized, the connector registers a video adapter (and an ads adapter) against the player +and reports playback automatically: + +- Playback events (start, join, pause/resume, buffering, seeking, end and errors) mapped from the + unified `PlayerEventType` events. +- Playhead, duration and live/VOD state, resource, rendition and bitrate. +- The active subtitle/caption track language (`getSubtitles`) and audio language (`getVideoLanguage`). +- Ads: ad breaks, ad start/join/stop, quartiles, skip, click and errors, with ad position + (`pre`/`mid`/`post`), duration and skippability. + +### Updating options at runtime + +```ts +// Update options for the current video (e.g. late-arriving metadata): +npaw.current?.setVideoOptions({ 'content.title': 'New Title' }); + +// Update global analytics options: +npaw.current?.setAnalyticsOptions({ 'user.name': 'viewer-1' }); + +// Change the log level: +npaw.current?.setLogLevel(LogLevel.SILENT); + +// Stop analytics and close all sessions: +npaw.current?.destroy(); +``` + +See the [NPAW integration documentation](https://documentation.npaw.com/integration-docs/docs/) +for the full set of plugin and analytics options. diff --git a/npaw/babel.config.js b/npaw/babel.config.js new file mode 100644 index 00000000..f842b77f --- /dev/null +++ b/npaw/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:metro-react-native-babel-preset'], +}; diff --git a/npaw/manifest.js b/npaw/manifest.js new file mode 100755 index 00000000..e7629f28 --- /dev/null +++ b/npaw/manifest.js @@ -0,0 +1,6 @@ +const manifest = { + version: process.env.npm_package_version, + buildDate: new Date().toISOString(), +}; + +console.log(JSON.stringify(manifest)); diff --git a/npaw/package.json b/npaw/package.json new file mode 100644 index 00000000..0142f317 --- /dev/null +++ b/npaw/package.json @@ -0,0 +1,75 @@ +{ + "name": "@theoplayer/react-native-analytics-npaw", + "version": "1.0.0", + "description": "NPAW analytics connector for @theoplayer/react-native", + "main": "lib/commonjs/index", + "module": "lib/module/index", + "types": "lib/typescript/src/index.d.ts", + "react-native": "src/index", + "source": "src/index", + "files": [ + "src", + "lib", + "*.tgz", + "CHANGELOG.md", + "!lib/typescript/example", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], + "scripts": { + "typescript": "tsc --noEmit", + "build": "bob build", + "manifest": "node manifest.js > src/manifest.json", + "prepare": "npm run manifest && npm run build", + "clean": "rimraf lib" + }, + "keywords": [ + "react-native", + "THEOplayer", + "npaw" + ], + "repository": { + "type": "git", + "url": "git@github.com:THEOplayer/react-native-connectors.git" + }, + "author": "Dolby Laboratories, Inc.", + "license": "BSD-3-Clause-Clear", + "homepage": "https://github.com/THEOplayer/react-native-connectors/tree/main/npaw#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "npaw-plugin-react-native": "^7.3.33" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-theoplayer": "^7 || ^8 || ^9 || ^10 || ^11", + "theoplayer": "^7 || ^8 || ^9 || ^10 || ^11" + }, + "peerDependenciesMeta": { + "theoplayer": { + "optional": true + } + }, + "eslintIgnore": [ + "node_modules/", + "lib/" + ], + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + "commonjs", + "module", + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + } +} diff --git a/npaw/src/__tests__/index.test.tsx b/npaw/src/__tests__/index.test.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/npaw/src/__tests__/index.test.tsx @@ -0,0 +1 @@ +export {}; diff --git a/npaw/src/api/LogLevel.ts b/npaw/src/api/LogLevel.ts new file mode 100644 index 00000000..5d8a9209 --- /dev/null +++ b/npaw/src/api/LogLevel.ts @@ -0,0 +1,8 @@ +export enum LogLevel { + SILENT = 6, + ERROR = 5, + WARNING = 4, + NOTICE = 3, + DEBUG = 2, + VERBOSE = 1, +} diff --git a/npaw/src/api/NpawConnector.ts b/npaw/src/api/NpawConnector.ts new file mode 100644 index 00000000..123b2e2a --- /dev/null +++ b/npaw/src/api/NpawConnector.ts @@ -0,0 +1,66 @@ +import type { THEOplayer } from 'react-native-theoplayer'; +import type NpawPluginOptions from 'npaw-plugin-react-native/dist/src/core/NpawPluginOptions'; +import { LogLevel } from './LogLevel'; +import { NpawConnectorAdapter } from '../internal/NpawConnectorAdapter'; + +export type NpawAnalyticsOptions = { [key: string]: unknown }; + +export interface NpawConnectorConfig { + /** NPAW account code. */ + accountCode: string; + /** Per-video analytics options (e.g. 'content.title', 'content.isLive', ...). */ + analytics?: NpawAnalyticsOptions; + /** NpawPlugin options (host, sessionRecovery, ...). */ + plugin?: NpawPluginOptions; + /** Initial log level. */ + logLevel?: LogLevel; +} + +export class NpawConnector { + private connectorAdapter: NpawConnectorAdapter; + + /** + * Create an NPAW connector. + * + * @public + */ + constructor(player: THEOplayer, config: NpawConnectorConfig) { + this.connectorAdapter = new NpawConnectorAdapter(player, config); + } + + /** + * Change log level. + * @param level + */ + setLogLevel(level: LogLevel) { + this.connectorAdapter.setLogLevel(level); + } + + /** + * Sets or updates NPAW analytics options for the current video. + * + * @param options Analytics options, such as `content.title` or `content.metadata`. + * @param videoKey Optional NPAW video key. + */ + setVideoOptions(options: NpawAnalyticsOptions, videoKey?: string): void { + this.connectorAdapter.setVideoOptions(options, videoKey); + } + + /** + * Sets or updates global NPAW analytics options. + * + * @param options Global analytics options. + */ + setAnalyticsOptions(options: NpawAnalyticsOptions): void { + this.connectorAdapter.setAnalyticsOptions(options); + } + + /** + * Stops video and ad analytics and closes all sessions. + * + * @public + */ + destroy(): void { + this.connectorAdapter.destroy(); + } +} diff --git a/npaw/src/api/hooks/useNpaw.ts b/npaw/src/api/hooks/useNpaw.ts new file mode 100644 index 00000000..fe819ac4 --- /dev/null +++ b/npaw/src/api/hooks/useNpaw.ts @@ -0,0 +1,39 @@ +import { PlayerEventType, THEOplayer } from 'react-native-theoplayer'; +import { RefObject, useEffect, useRef } from 'react'; +import { NpawConnector } from '../NpawConnector'; +import type { NpawConnectorConfig } from '../NpawConnector'; + +export function useNpaw(config: NpawConnectorConfig): [RefObject, (player: THEOplayer | undefined) => void] { + const connector = useRef(undefined); + const theoPlayer = useRef(undefined); + + const initialize = (player: THEOplayer | undefined) => { + // Optionally destroy existent connector + onDestroy(); + + theoPlayer.current = player; + if (player) { + connector.current = new NpawConnector(player, config); + player.addEventListener(PlayerEventType.DESTROY, onDestroy); + } else { + throw new Error('Invalid THEOplayer instance'); + } + }; + + const onDestroy = () => { + if (connector.current) { + if (!theoPlayer.current) { + throw new Error('Invalid THEOplayer instance'); + } + theoPlayer.current.removeEventListener(PlayerEventType.DESTROY, onDestroy); + connector.current.destroy(); + connector.current = undefined; + } + }; + + useEffect(() => { + return onDestroy; + }, []); + + return [connector, initialize]; +} diff --git a/npaw/src/index.ts b/npaw/src/index.ts new file mode 100644 index 00000000..be0b4f27 --- /dev/null +++ b/npaw/src/index.ts @@ -0,0 +1,7 @@ +import NpawPlugin from 'npaw-plugin-react-native'; +import { LogLevel } from './api/LogLevel'; + +export { NpawConnector } from './api/NpawConnector'; +export type { NpawConnectorConfig, NpawAnalyticsOptions } from './api/NpawConnector'; +export { useNpaw } from './api/hooks/useNpaw'; +export { NpawPlugin, LogLevel }; diff --git a/npaw/src/internal/NpawConnectorAdapter.ts b/npaw/src/internal/NpawConnectorAdapter.ts new file mode 100644 index 00000000..8955a4de --- /dev/null +++ b/npaw/src/internal/NpawConnectorAdapter.ts @@ -0,0 +1,63 @@ +import type { THEOplayer } from 'react-native-theoplayer'; +import NpawPlugin from 'npaw-plugin-react-native'; +import type { NpawAnalyticsOptions } from '../api/NpawConnector'; +import type { NpawConnectorConfig } from '../api/NpawConnector'; +import type { LogLevel } from '../api/LogLevel'; +import { TheoplayerAdsAdapter } from './TheoplayerAdsAdapter'; +import { TheoplayerAdapter } from './TheoplayerAdapter'; + +const TAG = 'NpawConnector'; +const ERROR_MSG = 'NpawConnectorAdapter Error'; + +export class NpawConnectorAdapter { + private plugin?: NpawPlugin; + + constructor(player: THEOplayer, config: NpawConnectorConfig) { + try { + this.plugin = new NpawPlugin(config.accountCode, config.plugin); + if (config.logLevel != null) this.plugin.setLogLevel(config.logLevel); + this.plugin.registerAdapterFromClass(player, TheoplayerAdapter, config.analytics); + this.plugin.registerAdsAdapterFromClass(player, TheoplayerAdsAdapter, config.analytics); + } catch (error: unknown) { + console.error(TAG, `${ERROR_MSG}: ${error}`); + } + } + + setLogLevel(level: LogLevel) { + try { + this.plugin?.setLogLevel(level); + } catch (error: unknown) { + console.error(TAG, `${ERROR_MSG}: ${error}`); + } + } + + setVideoOptions(options: NpawAnalyticsOptions, videoKey?: string): void { + try { + this.plugin?.setVideoOptions(options, videoKey); + } catch (error: unknown) { + console.error(TAG, `${ERROR_MSG}: ${error}`); + } + } + + setAnalyticsOptions(options: NpawAnalyticsOptions): void { + try { + this.plugin?.setAnalyticsOptions(options); + } catch (error: unknown) { + console.error(TAG, `${ERROR_MSG}: ${error}`); + } + } + + destroy(): void { + try { + // NPAW's removeAdapter only disposes the adapter; end any in-progress view first. + this.plugin?.getAdsAdapter()?.fireStop(); + this.plugin?.getAdapter()?.fireStop(); + this.plugin?.removeAdapter(); + this.plugin?.removeAdsAdapter(); + this.plugin?.destroy(); + } catch (error: unknown) { + console.error(TAG, `${ERROR_MSG}: ${error}`); + } + this.plugin = undefined; + } +} diff --git a/npaw/src/internal/TheoplayerAdapter.ts b/npaw/src/internal/TheoplayerAdapter.ts new file mode 100644 index 00000000..39ebf3fc --- /dev/null +++ b/npaw/src/internal/TheoplayerAdapter.ts @@ -0,0 +1,247 @@ +// noinspection JSUnusedGlobalSymbols + +import { + type EventListener, + isVideoQuality, + type PlayerEventMap, + PlayerEventType, + TextTrackKind, + TextTrackMode, + type SourceDescription, + type THEOplayer, + type VideoQuality, +} from 'react-native-theoplayer'; +import manifest from '../manifest.json'; + +interface NpawPlaybackFlags { + isStarted: boolean; + isJoined: boolean; + isPaused: boolean; + isSeeking: boolean; + isBuffering: boolean; + reset(): void; +} + +type PlayerEvent = PlayerEventMap[PlayerEventType]; +type PlayerListener = EventListener; +type PlayerReference = { + type: PlayerEventType; + listener: PlayerListener; +}; + +export class TheoplayerAdapter { + /** + * Provided at runtime by NPAW's base video adapter; declared for typing only. + * Implementations are copied onto the instance during registerAdapterFromClass. + */ + protected player!: THEOplayer; + protected flags!: NpawPlaybackFlags; + protected references!: PlayerReference[]; + + protected fireStart!: (params?: object, triggeredEvent?: string) => void; + protected fireJoin!: (params?: object, triggeredEvent?: string) => void; + protected firePause!: (params?: object, triggeredEvent?: string) => void; + protected fireResume!: (params?: object, triggeredEvent?: string) => void; + protected fireBufferBegin!: (params?: object, convertFromSeek?: boolean, triggeredEvent?: string) => void; + protected fireBufferEnd!: (params?: object, triggeredEvent?: string) => void; + protected fireStop!: (params?: object, triggeredEvent?: string) => void; + protected fireError!: (code?: string | object, msg?: string, metadata?: object, level?: string, triggeredEvent?: string) => void; + protected fireSeekBegin!: (params?: object, convertFromBuffer?: boolean, triggeredEvent?: string) => void; + protected fireSeekEnd!: (params?: object, triggeredEvent?: string) => void; + protected getNpawUtils!: () => { + buildRenditionString(width?: number, height?: number, bitrate?: number): string; + }; + + private lastPlayhead?: number; + private currentSource?: SourceDescription; + + getPlayhead(): number | undefined { + try { + this.lastPlayhead = this.player.currentTime / 1000; + return this.lastPlayhead; + } catch { + return undefined; + } + } + + getDuration(): number | undefined { + const duration = this.player.duration; + return Number.isFinite(duration) ? duration / 1000 : undefined; + } + + getIsLive(): boolean { + return this.player.duration === Infinity; + } + + getResource(): string | undefined { + const sources = this.player.source?.sources; + const source = Array.isArray(sources) ? sources[0] : sources; + return source?.src; + } + + getSubtitles(): string | undefined { + const track = this.player?.textTracks?.find( + (textTrack) => + textTrack.mode === TextTrackMode.showing && (textTrack.kind === TextTrackKind.subtitles || textTrack.kind === TextTrackKind.captions), + ); + return track?.language || track?.label || undefined; + } + + getVideoLanguage(): string | undefined { + const track = this.player?.audioTracks?.find((audioTrack) => audioTrack.enabled); + return track?.language || track?.label || undefined; + } + + getRendition(): string | undefined { + const quality = this.getActiveQuality(); + if (!quality || !isVideoQuality(quality)) { + return undefined; + } + return this.getNpawUtils().buildRenditionString(quality.width, quality.height, quality.bandwidth); + } + + getBitrate(): number | undefined { + return this.getActiveQuality()?.bandwidth; + } + + getPlayerName(): string { + return 'THEOplayer'; + } + + getPlayerVersion(): string | undefined { + return this.player?.version?.version; + } + + getVersion(): string { + return `${manifest.version}-react-native-theoplayerclass`; + } + + registerListeners(): void { + this.references = [ + this.reference(PlayerEventType.PLAY, this.onPlay), + this.reference(PlayerEventType.PLAYING, this.onPlaying), + this.reference(PlayerEventType.PAUSE, this.onPause), + this.reference(PlayerEventType.WAITING, this.onWaiting), + this.reference(PlayerEventType.SEEKING, this.onSeeking), + this.reference(PlayerEventType.SEEKED, this.onSeeked), + this.reference(PlayerEventType.TIME_UPDATE, this.onTimeUpdate), + this.reference(PlayerEventType.SOURCE_CHANGE, this.onSourceChange), + this.reference(PlayerEventType.ENDED, this.onEnded), + this.reference(PlayerEventType.ERROR, this.onError), + ]; + this.currentSource = this.player.source; + + for (const reference of this.references) { + this.player.addEventListener(reference.type, reference.listener); + } + } + + unregisterListeners(): void { + for (const reference of this.references) { + this.player.removeEventListener(reference.type, reference.listener); + } + this.references = []; + this.currentSource = undefined; + } + + private reference(type: PlayerEventType, listener: PlayerListener): PlayerReference { + return { + type, + listener: listener.bind(this), + }; + } + + private getActiveQuality(): VideoQuality | undefined { + return this.player.videoTracks.find((track) => track.enabled && isVideoQuality(track.activeQuality))?.activeQuality as VideoQuality | undefined; + } + + private onPlay(): void { + if (!this.flags.isStarted) { + this.fireStart({}, 'onPlay'); + } + } + + private onPlaying(): void { + if (this.flags.isSeeking && !this.flags.isPaused) { + this.fireSeekEnd({}, 'onPlaying'); + } + if (this.flags.isBuffering && !this.flags.isPaused) { + this.fireBufferEnd({}, 'onPlaying'); + } + this.fireResume({}, 'onPlaying'); + if (!this.flags.isStarted) { + this.fireStart({}, 'onPlaying'); + } + if (!this.flags.isJoined) { + this.fireJoin({}, 'onPlaying'); + } + } + + private onPause(): void { + if (!this.flags.isSeeking) { + this.firePause({}, 'onPause'); + } + } + + private onWaiting(): void { + if (!this.flags.isPaused && !this.flags.isSeeking) { + this.fireBufferBegin({}, false, 'onWaiting'); + } + } + + private onSeeking(): void { + if (!this.flags.isSeeking) { + this.fireSeekBegin({}, false, 'onSeeking'); + } + } + + private onSeeked(): void { + if (this.flags.isSeeking) { + this.fireSeekEnd({}, 'onSeeked'); + } + } + + private onTimeUpdate(event: PlayerEvent): void { + this.lastPlayhead = 'currentTime' in event && typeof event.currentTime === 'number' ? event.currentTime / 1000 : this.player.currentTime / 1000; + if (!this.flags.isStarted && this.lastPlayhead > 0) { + this.fireStart({}, 'onTimeUpdate'); + this.fireJoin({}, 'onTimeUpdate'); + } + if (!this.flags.isPaused) { + this.fireSeekEnd({}, 'onTimeUpdate'); + this.fireBufferEnd({}, 'onTimeUpdate'); + } + } + + private onSourceChange(): void { + const source = this.player.source; + if (source === this.currentSource) { + return; + } + this.currentSource = source; + if (this.flags.isStarted) { + this.fireStop({}, 'onSourceChange'); + } + this.flags.reset(); + this.lastPlayhead = undefined; + } + + private onEnded(): void { + if (this.flags.isStarted) { + this.fireStop({}, 'onEnded'); + } + } + + private onError(event: PlayerEvent): void { + if ('error' in event) { + const error = event.error; + if (typeof error === 'string') { + this.fireError('GENERIC_ERROR', error, undefined, undefined, 'onError'); + } else if (typeof error === 'object' && error !== null && 'errorCode' in error && 'errorMessage' in error) { + this.fireError(String(error.errorCode), String(error.errorMessage), undefined, undefined, 'onError'); + } else { + this.fireError('GENERIC_ERROR', String(error), undefined, undefined, 'onError'); + } + } + } +} diff --git a/npaw/src/internal/TheoplayerAdsAdapter.ts b/npaw/src/internal/TheoplayerAdsAdapter.ts new file mode 100644 index 00000000..18f9650a --- /dev/null +++ b/npaw/src/internal/TheoplayerAdsAdapter.ts @@ -0,0 +1,156 @@ +import { AdEventType, PlayerEventType, type Ad, type AdBreak, type AdEvent, type EventListener, type THEOplayer } from 'react-native-theoplayer'; +import manifest from '../manifest.json'; + +type AdReference = { + type: PlayerEventType.AD_EVENT; + listener: EventListener; +}; + +export class TheoplayerAdsAdapter { + /** + * Provided at runtime by NPAW's base ads adapter; declared for typing only. + * Implementations are copied onto the instance during registerAdsAdapterFromClass. + */ + protected player!: THEOplayer; + protected references!: AdReference[]; + + protected fireStart!: (params?: object, triggeredEvent?: string) => void; + protected fireJoin!: (params?: object, triggeredEvent?: string) => void; + protected fireStop!: (params?: object, triggeredEvent?: string) => void; + protected fireError!: ( + code?: string | object, + msg?: string, + metadata?: object, + level?: string, + triggeredEvent?: string, + fatalError?: boolean, + duration?: number, + ) => void; + protected fireClick!: (params?: object) => void; + protected fireQuartile!: (quartile: number) => void; + protected fireSkip!: (params?: object) => void; + protected fireBreakStart!: (params?: object) => void; + protected fireBreakStop!: (params?: object) => void; + + private currentAd?: Ad; + private currentAdBreak?: AdBreak; + + getDuration(): number | undefined { + return this.currentAd?.duration; + } + + getResource(): string | undefined { + return this.currentAd?.resourceURI; + } + + getTitle(): string | undefined { + return this.currentAd?.id; + } + + getPosition(): 'pre' | 'mid' | 'post' | undefined { + const timeOffset = this.currentAdBreak?.timeOffset; + const duration = this.player?.duration; + if (timeOffset == null) { + return undefined; + } + if (timeOffset === 0) { + return 'pre'; + } + if (timeOffset < 0 || (Number.isFinite(duration) && timeOffset >= duration / 1000)) { + return 'post'; + } + return 'mid'; + } + + getIsSkippable(): boolean { + const skipOffset = this.currentAd?.skipOffset; + return skipOffset != null && skipOffset >= 0; + } + + getPlayerName(): string { + return 'THEOplayer'; + } + + getPlayerVersion(): string | undefined { + return this.player?.version?.version; + } + + getVersion(): string { + return `${manifest.version}-react-native-theoplayeradsclass`; + } + + registerListeners(): void { + this.references = [ + { + type: PlayerEventType.AD_EVENT, + listener: this.onAdEvent.bind(this), + }, + ]; + + for (const reference of this.references) { + this.player.addEventListener(reference.type, reference.listener); + } + } + + unregisterListeners(): void { + for (const reference of this.references) { + this.player?.removeEventListener(reference.type, reference.listener); + } + this.references = []; + } + + private onAdEvent(event: AdEvent): void { + switch (event.subType) { + case AdEventType.AD_BREAK_BEGIN: + if (this.isAdBreak(event.ad)) { + this.currentAdBreak = event.ad; + this.fireBreakStart(); + } + break; + case AdEventType.AD_BEGIN: + if (this.isAd(event.ad)) { + this.currentAd = event.ad; + this.fireStart(); + this.fireJoin(); + } + break; + case AdEventType.AD_FIRST_QUARTILE: + this.fireQuartile(1); + break; + case AdEventType.AD_MIDPOINT: + this.fireQuartile(2); + break; + case AdEventType.AD_THIRD_QUARTILE: + this.fireQuartile(3); + break; + case AdEventType.AD_SKIP: + this.fireSkip(); + break; + case AdEventType.AD_CLICKED: + this.fireClick(); + break; + case AdEventType.AD_END: + this.fireStop(); + this.currentAd = undefined; + break; + case AdEventType.AD_BREAK_END: + this.fireBreakStop(); + this.currentAdBreak = undefined; + break; + case AdEventType.AD_ERROR: + this.fireError('AD_ERROR', 'NPAW ad error', undefined, undefined, 'onAdError'); + break; + case AdEventType.AD_BUFFERING: + // THEOplayer does not provide a reliable matching ad buffer-end event. + break; + } + } + + private isAd(value: Ad | AdBreak): value is Ad { + return 'adBreak' in value; + } + + private isAdBreak(value: Ad | AdBreak): value is AdBreak { + return 'ads' in value || 'timeOffset' in value; + } +} diff --git a/npaw/tsconfig.build.json b/npaw/tsconfig.build.json new file mode 100644 index 00000000..f929194b --- /dev/null +++ b/npaw/tsconfig.build.json @@ -0,0 +1,5 @@ + +{ + "extends": "./tsconfig", + "exclude": ["example", "ios", "android"] +} diff --git a/npaw/tsconfig.json b/npaw/tsconfig.json new file mode 100644 index 00000000..a4c934d8 --- /dev/null +++ b/npaw/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@theoplayer/react-native-analytics-npaw": [ + "./src/index" + ] + } + } +} diff --git a/npaw/typedoc.json b/npaw/typedoc.json new file mode 100644 index 00000000..ae2d161b --- /dev/null +++ b/npaw/typedoc.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": [ + "../typedoc.base.json" + ], + "entryPoints": [ + "src/index.ts" + ], + "tsconfig": "tsconfig.json", + "readme": "README.md", + "name": "NPAW Connector" +} diff --git a/package-lock.json b/package-lock.json index d664e005..369de221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "engage", "mux", "nielsen", + "npaw", "yospace", "youbora", "gemius" @@ -137,7 +138,7 @@ }, "bitmovin": { "name": "@theoplayer/react-native-analytics-bitmovin", - "version": "1.4.1", + "version": "1.5.0", "license": "BSD-3-Clause-Clear", "dependencies": { "bitmovin-analytics": "^2.51.0" @@ -5725,6 +5726,10 @@ "resolved": "nielsen", "link": true }, + "node_modules/@theoplayer/react-native-analytics-npaw": { + "resolved": "npaw", + "link": true + }, "node_modules/@theoplayer/react-native-analytics-youbora": { "resolved": "youbora", "link": true @@ -6482,6 +6487,20 @@ "win32" ] }, + "node_modules/@videojs/vhs-utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.2.tgz", + "integrity": "sha512-1Dsv5dtWOq0LP1G37O46FQFA2tKAZON5UMe6dFPxT7JvPhEx8QIXcFQxan8+hHI3fX6aomFRFrUBJNgRYQDSXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", @@ -6640,6 +6659,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -8249,6 +8277,17 @@ "node": ">=10" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", @@ -8691,6 +8730,11 @@ "node": ">=6.0.0" } }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10244,6 +10288,16 @@ "node": ">=10.13.0" } }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "license": "MIT", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -10471,6 +10525,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls-parser": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/hls-parser/-/hls-parser-0.10.6.tgz", + "integrity": "sha512-t9bKB1fDDVajh5CY/qeZanNMnCm4hAWMroDlflnuboOskYR26z29bEyOYwQRP1MxP2Y83UC2J93DQCC1AMhBCA==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -13875,6 +13935,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-document": { + "version": "2.19.2", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -13930,6 +13999,21 @@ "node": "*" } }, + "node_modules/mpd-parser": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.4.0.tgz", + "integrity": "sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^4.1.2", + "@xmldom/xmldom": "^0.8.3", + "global": "^4.4.0" + }, + "bin": { + "mpd-to-m3u8-json": "bin/parse.js" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -14078,6 +14162,20 @@ "node": ">=0.10.0" } }, + "node_modules/npaw-plugin-react-native": { + "version": "7.3.33", + "resolved": "https://registry.npmjs.org/npaw-plugin-react-native/-/npaw-plugin-react-native-7.3.33.tgz", + "integrity": "sha512-dkkq1z1ducvf/l4/rPcKKOqpKlBrJ+rGlv/OyOhHCDV/5siafYyPLirmUpBaIzMv/LCwpA9nhz/SUO7fGhrcPQ==", + "license": "MIT", + "dependencies": { + "core-js": "^3.33.0", + "hls-parser": "0.10.6", + "mpd-parser": "^1.3.1" + }, + "engines": { + "node": ">= 10.13" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -14812,6 +14910,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -19050,6 +19157,25 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "npaw": { + "name": "@theoplayer/react-native-analytics-npaw", + "version": "1.0.0", + "license": "BSD-3-Clause-Clear", + "dependencies": { + "npaw-plugin-react-native": "^7.3.33" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-theoplayer": "^7 || ^8 || ^9 || ^10 || ^11", + "theoplayer": "^7 || ^8 || ^9 || ^10 || ^11" + }, + "peerDependenciesMeta": { + "theoplayer": { + "optional": true + } + } + }, "yospace": { "name": "@theoplayer/react-native-yospace", "version": "1.7.0", diff --git a/package.json b/package.json index 5baa9c64..4a6b9ad4 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "engage", "mux", "nielsen", + "npaw", "yospace", "youbora", "gemius"