-
Notifications
You must be signed in to change notification settings - Fork 8
conviva: preserve startup session across early sourcechange #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit-pick: we usually prefer
Suggested change
|
||||||
|
|
||||||
| 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) => { | ||||||
|
|
||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you add |
||
| }); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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.mddirectly, instead usenpx changeset. The bot has links that explain how to do this. 😉