Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions conviva/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @theoplayer/conviva-connector-web

## Unreleased

### ✨ Features

- Added optional startup source-change preservation configuration:
- `preserveSessionOnStartupSourceChange` (default `false`)
- `startupGraceMs` (default `10000`)

Comment on lines +3 to +10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not modify CHANGELOG.md directly, instead use npx changeset. The bot has links that explain how to do this. 😉

## 3.2.0

### ✨ Features
Expand Down
8 changes: 7 additions & 1 deletion conviva/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 52 additions & 2 deletions conviva/src/integration/ConvivaHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -58,6 +77,7 @@ export class ConvivaHandler {

private currentSource: SourceDescription | undefined;
private playbackRequested: boolean = false;
private startupAt: number | null = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit-pick: we usually prefer undefined instead of null.

Suggested change
private startupAt: number | null = null;
private startupAt: number | undefined = undefined;


private yospaceConnector: YospaceConnector | undefined;

Expand All @@ -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());
Expand Down Expand Up @@ -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();
};

Expand All @@ -269,6 +310,7 @@ export class ConvivaHandler {
this.convivaVideoAnalytics?.reportPlaybackEnded();
this.releaseSession();
this.playbackRequested = false;
this.clearStartup();
}
}

Expand Down Expand Up @@ -306,6 +348,7 @@ export class ConvivaHandler {
}

private readonly onPlaying = () => {
this.clearStartup();
this.convivaVideoAnalytics?.reportPlaybackMetric(
Constants.Playback.PLAYER_STATE,
Constants.PlayerState.PLAYING
Expand Down Expand Up @@ -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) => {
Expand Down
224 changes: 211 additions & 13 deletions conviva/test/unit/ConvivaConnector.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<EventListener>>();

addEventListener(type: string, listener: EventListener): void {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set<EventListener>());
}
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<any> = [{ activeQuality: undefined }];
public abr = {
targetBuffer: undefined,
bufferLookbackWindow: undefined,
strategy: undefined
};
public ads: Array<any> = [];
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()
};
}
Comment on lines +5 to +127
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you move these mocks to a separate file? They are distracting a bit from the tests themselves.


describe('ConvivaConnector', () => {
let player: ChromelessPlayer;
let player: FakePlayer;
let videoAnalytics: ReturnType<typeof createVideoAnalyticsMock>;
let adAnalytics: ReturnType<typeof createAdAnalyticsMock>;

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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you add let connector: ConvivaConnector to the surrounding describe, we can put connector?.destroy() inside the afterEach. 😉

});

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();
});
});
Loading