diff --git a/conviva/CHANGELOG.md b/conviva/CHANGELOG.md index ae2e072..f2387d7 100644 --- a/conviva/CHANGELOG.md +++ b/conviva/CHANGELOG.md @@ -1,5 +1,13 @@ # @theoplayer/conviva-connector-web +## Unreleased + +### ✨ Features + +- Added optional startup source-change preservation configuration: + - `preserveSessionOnStartupSourceChange` (default `false`) + - `startupGraceMs` (default `10000`) + ## 3.2.0 ### ✨ Features diff --git a/conviva/README.md b/conviva/README.md index 135cd2e..f1777d1 100644 --- a/conviva/README.md +++ b/conviva/README.md @@ -39,10 +39,16 @@ First you need to define the Conviva metadata and configuration: const convivaConfig = { debug: false, gatewayUrl: 'CUSTOMER_GATEWAY_GOES_HERE', - customerKey: 'CUSTOMER_KEY_GOES_HERE' // Can be a test or production key. + customerKey: 'CUSTOMER_KEY_GOES_HERE', // Can be a test or production key. + preserveSessionOnStartupSourceChange: false, // Optional, default false. + startupGraceMs: 10000 // Optional, default 10000 ms. }; ``` +When `preserveSessionOnStartupSourceChange` is enabled, early `sourcechange` events between the first `play` and the first +`playing` event are treated as startup transitions and do not end the current session as long as they happen within +`startupGraceMs`. + Optionally, you can include device metadata in the ConvivaConfiguration object. Note that `SCREEN_RESOLUTION_WIDTH`, `SCREEN_RESOLUTION_HEIGHT` and `SCREEN_RESOLUTION_SCALE_FACTOR` are the only fields that Conviva will auto-collect on most web-based platforms. ```typescript diff --git a/conviva/src/integration/ConvivaHandler.ts b/conviva/src/integration/ConvivaHandler.ts index bf0621d..fcf0eea 100644 --- a/conviva/src/integration/ConvivaHandler.ts +++ b/conviva/src/integration/ConvivaHandler.ts @@ -35,17 +35,36 @@ enum CustomConstants { ENCODING_TYPE = 'encoding_type' } +const DEFAULT_STARTUP_GRACE_MS = 10_000; + export interface ConvivaConfiguration { customerKey: string; debug?: boolean; gatewayUrl?: string; deviceMetadata?: ConvivaDeviceMetadata; + /** + * When enabled, do not end the session on early sourcechange events that happen + * between first play and first playing (startup phase). + * Default: false (backward compatible). + */ + preserveSessionOnStartupSourceChange?: boolean; + /** + * Maximum startup preservation window in milliseconds. + * Only used when preserveSessionOnStartupSourceChange is true. + * Default: 10000. + */ + startupGraceMs?: number; } +type NormalizedConvivaConfiguration = ConvivaConfiguration & { + preserveSessionOnStartupSourceChange: boolean; + startupGraceMs: number; +}; + export class ConvivaHandler { private readonly player: ChromelessPlayer; private readonly convivaMetadata: ConvivaMetadata; - private readonly convivaConfig: ConvivaConfiguration; + private readonly convivaConfig: NormalizedConvivaConfiguration; private customMetadata: ConvivaMetadata = {}; private convivaVideoAnalytics: VideoAnalytics | undefined; @@ -58,6 +77,7 @@ export class ConvivaHandler { private currentSource: SourceDescription | undefined; private playbackRequested: boolean = false; + private startupAt: number | null = null; private yospaceConnector: YospaceConnector | undefined; @@ -66,7 +86,11 @@ export class ConvivaHandler { constructor(player: ChromelessPlayer, convivaMetaData: ConvivaMetadata, config: ConvivaConfiguration) { this.player = player; this.convivaMetadata = convivaMetaData; - this.convivaConfig = config; + this.convivaConfig = { + ...config, + preserveSessionOnStartupSourceChange: config.preserveSessionOnStartupSourceChange ?? false, + startupGraceMs: config.startupGraceMs ?? DEFAULT_STARTUP_GRACE_MS + }; this.currentSource = player.source; Analytics.setDeviceMetadata(this.convivaConfig.deviceMetadata ?? collectDefaultDeviceMetadata()); @@ -249,7 +273,24 @@ export class ConvivaHandler { } }; + private markStartup(): void { + if (this.startupAt === null) { + this.startupAt = Date.now(); + } + } + + private clearStartup(): void { + this.startupAt = null; + } + + private shouldPreserveSessionOnSourceChange(): boolean { + if (!this.convivaConfig.preserveSessionOnStartupSourceChange) return false; + if (!this.playbackRequested || this.startupAt === null) return false; + return Date.now() - this.startupAt <= this.convivaConfig.startupGraceMs; + } + private readonly onPlay = () => { + this.markStartup(); this.maybeReportPlaybackRequested(); }; @@ -269,6 +310,7 @@ export class ConvivaHandler { this.convivaVideoAnalytics?.reportPlaybackEnded(); this.releaseSession(); this.playbackRequested = false; + this.clearStartup(); } } @@ -306,6 +348,7 @@ export class ConvivaHandler { } private readonly onPlaying = () => { + this.clearStartup(); this.convivaVideoAnalytics?.reportPlaybackMetric( Constants.Playback.PLAYER_STATE, Constants.PlayerState.PLAYING @@ -376,9 +419,16 @@ export class ConvivaHandler { }; private readonly onSourceChange = () => { + if (this.shouldPreserveSessionOnSourceChange()) { + // Keep startup anchored to first play; refresh source metadata only. + this.currentSource = this.player.source; + this.reportMetadata(); + return; + } this.maybeReportPlaybackEnded(); this.currentSource = this.player.source; this.customMetadata = {}; + this.clearStartup(); }; private readonly onCurrentSourceChange = (event: CurrentSourceChangeEvent) => { diff --git a/conviva/test/unit/ConvivaConnector.spec.ts b/conviva/test/unit/ConvivaConnector.spec.ts index ba37d9e..1dbf92a 100644 --- a/conviva/test/unit/ConvivaConnector.spec.ts +++ b/conviva/test/unit/ConvivaConnector.spec.ts @@ -1,24 +1,222 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { type ConvivaConfiguration, ConvivaConnector } from '@theoplayer/conviva-connector-web'; -import { ConvivaMetadata } from '@convivainc/conviva-js-coresdk'; -import { ChromelessPlayer } from 'theoplayer'; -import { afterEach } from 'node:test'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type ConvivaConfiguration, ConvivaConnector } from '../../src'; +import { Analytics } from '../../src/utils/ConvivaSdk'; + +vi.mock('../../src/integration/ads/AdReporter', () => ({ + AdReporter: class { + destroy(): void {} + } +})); + +vi.mock('../../src/integration/ads/YospaceAdReporter', () => ({ + YospaceAdReporter: class { + destroy(): void {} + } +})); + +vi.mock('../../src/integration/ads/UplynkAdReporter', () => ({ + UplynkAdReporter: class { + destroy(): void {} + } +})); + +vi.mock('../../src/integration/theolive/THEOliveReporter', () => ({ + THEOliveReporter: class { + destroy(): void {} + } +})); + +vi.mock('../../src/utils/ErrorReportBuilder', () => ({ + ErrorReportBuilder: class { + destroy(): void {} + + withPlayerBuffer() { + return this; + } + + withErrorDetails() { + return this; + } + + build() { + return undefined; + } + } +})); + +type EventListener = (event?: unknown) => void; + +class FakeEventDispatcher { + private readonly listeners = new Map>(); + + addEventListener(type: string, listener: EventListener): void { + if (!this.listeners.has(type)) { + this.listeners.set(type, new Set()); + } + this.listeners.get(type)!.add(listener); + } + + removeEventListener(type: string, listener: EventListener): void { + this.listeners.get(type)?.delete(listener); + } + + emit(type: string, event?: unknown): void { + this.listeners.get(type)?.forEach((listener) => listener(event)); + } +} + +class FakePlayer extends FakeEventDispatcher { + public source: any; + public src: string | undefined; + public paused: boolean = true; + public ended: boolean = false; + public readyState: number = 3; + public duration: number = 120; + public currentTime: number = 0; + public videoWidth: number = 1920; + public videoHeight: number = 1080; + public videoTracks: Array = [{ activeQuality: undefined }]; + public abr = { + targetBuffer: undefined, + bufferLookbackWindow: undefined, + strategy: undefined + }; + public ads: Array = []; + public uplynk: undefined = undefined; + public network = new FakeEventDispatcher(); + + setSource(src: string, title = 'Asset'): void { + this.source = { + sources: { + src, + type: 'application/vnd.apple.mpegurl' + }, + metadata: { title } + }; + this.src = src; + } +} + +function emitPlayerEvent(player: FakePlayer, type: string): void { + if (type === 'play') player.paused = false; + if (type === 'pause') player.paused = true; + if (type === 'ended') player.ended = true; + player.emit(type); +} + +function createVideoAnalyticsMock() { + return { + setPlayerInfo: vi.fn(), + setCallback: vi.fn(), + reportPlaybackRequested: vi.fn(), + reportPlaybackEnded: vi.fn(), + reportPlaybackMetric: vi.fn(), + setContentInfo: vi.fn(), + reportPlaybackFailed: vi.fn(), + reportPlaybackEvent: vi.fn(), + reportPlaybackError: vi.fn(), + release: vi.fn() + }; +} + +function createAdAnalyticsMock() { + return { + setAdInfo: vi.fn(), + release: vi.fn() + }; +} describe('ConvivaConnector', () => { - let player: ChromelessPlayer; + let player: FakePlayer; + let videoAnalytics: ReturnType; + let adAnalytics: ReturnType; + beforeEach(() => { - player = new ChromelessPlayer(document.createElement('div')); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + player = new FakePlayer(); + videoAnalytics = createVideoAnalyticsMock(); + adAnalytics = createAdAnalyticsMock(); + + vi.spyOn(Analytics, 'setDeviceMetadata').mockImplementation(() => {}); + vi.spyOn(Analytics, 'init').mockImplementation(() => {}); + vi.spyOn(Analytics, 'buildVideoAnalytics').mockReturnValue(videoAnalytics as any); + vi.spyOn(Analytics, 'buildAdAnalytics').mockReturnValue(adAnalytics as any); + vi.spyOn(Analytics, 'reportAppForegrounded').mockImplementation(() => {}); + vi.spyOn(Analytics, 'reportAppBackgrounded').mockImplementation(() => {}); + vi.spyOn(Analytics, 'release').mockImplementation(() => {}); }); + afterEach(() => { - player?.destroy(); + vi.useRealTimers(); + vi.restoreAllMocks(); }); it('can be constructed', () => { - const convivaMetadata: ConvivaMetadata = {}; - const convivaConfig: ConvivaConfiguration = { - customerKey: 'test' - }; - const connector = new ConvivaConnector(player, convivaMetadata, convivaConfig); + const convivaConfig: ConvivaConfiguration = { customerKey: 'test' }; + const connector = new ConvivaConnector(player as any, {}, convivaConfig); expect(connector).toBeDefined(); + connector.destroy(); + }); + + it('preserves startup session on early sourcechange when enabled', () => { + const connector = new ConvivaConnector(player as any, {}, { + customerKey: 'test', + preserveSessionOnStartupSourceChange: true + }); + player.setSource('https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'); + emitPlayerEvent(player, 'play'); + const contentInfoCallsAfterPlay = videoAnalytics.setContentInfo.mock.calls.length; + + player.setSource('https://cdn.theoplayer.com/video/big-buck-bunny/playlist.m3u8'); + emitPlayerEvent(player, 'sourcechange'); + + expect(videoAnalytics.reportPlaybackRequested).toHaveBeenCalledTimes(1); + expect(videoAnalytics.reportPlaybackEnded).not.toHaveBeenCalled(); + expect(videoAnalytics.setContentInfo).toHaveBeenCalledTimes(contentInfoCallsAfterPlay + 1); + connector.destroy(); + }); + + it('ends startup session on early sourcechange when grace window elapsed', () => { + const connector = new ConvivaConnector(player as any, {}, { + customerKey: 'test', + preserveSessionOnStartupSourceChange: true, + startupGraceMs: 10 + }); + player.setSource('https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'); + emitPlayerEvent(player, 'play'); + vi.advanceTimersByTime(11); + + player.setSource('https://cdn.theoplayer.com/video/big-buck-bunny/playlist.m3u8'); + emitPlayerEvent(player, 'sourcechange'); + + expect(videoAnalytics.reportPlaybackEnded).toHaveBeenCalledTimes(1); + connector.destroy(); + }); + + it('keeps backward-compatible behavior when preserve flag is omitted', () => { + const connector = new ConvivaConnector(player as any, {}, { customerKey: 'test' }); + player.setSource('https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'); + emitPlayerEvent(player, 'play'); + player.setSource('https://cdn.theoplayer.com/video/big-buck-bunny/playlist.m3u8'); + emitPlayerEvent(player, 'sourcechange'); + + expect(videoAnalytics.reportPlaybackEnded).toHaveBeenCalledTimes(1); + connector.destroy(); + }); + + it('ends session on sourcechange after playing even when preserve flag is enabled', () => { + const connector = new ConvivaConnector(player as any, {}, { + customerKey: 'test', + preserveSessionOnStartupSourceChange: true + }); + player.setSource('https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'); + emitPlayerEvent(player, 'play'); + emitPlayerEvent(player, 'playing'); + player.setSource('https://cdn.theoplayer.com/video/big-buck-bunny/playlist.m3u8'); + emitPlayerEvent(player, 'sourcechange'); + + expect(videoAnalytics.reportPlaybackEnded).toHaveBeenCalledTimes(1); + connector.destroy(); }); });