Skip to content

Commit 6bb5ea3

Browse files
refactor: video analytics support mux embedded iframe (#1636)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 38562e4 commit 6bb5ea3

File tree

5 files changed

+360
-13
lines changed

5 files changed

+360
-13
lines changed

packages/analytics-core/src/video-analytics/track-video.ts

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
1-
import { VideoHandler, StartVideoEvent, PauseVideoEvent, EndedVideoEvent, MuxElement } from './types';
1+
import {
2+
VideoHandler,
3+
StartVideoEvent,
4+
PauseVideoEvent,
5+
EndedVideoEvent,
6+
MuxEmbeddedPlayer,
7+
MuxElement,
8+
} from './types';
29

310
function getPlayData(videoEl: HTMLVideoElement | MuxElement) {
411
return {
512
program_duration: videoEl.duration,
613
};
714
}
815

9-
function getPauseData(videoEl: HTMLVideoElement | MuxElement) {
10-
const currentTime = videoEl.currentTime;
11-
const duration = videoEl.duration;
12-
16+
function calculatePercentCompleted(currentTime: number, duration: number) {
1317
let percentCompleted = 0;
14-
1518
if (Number.isFinite(currentTime) && Number.isFinite(duration) && duration > 0) {
1619
const rawPercent = (currentTime / duration) * 100;
1720
// Clamp to [0, 100] to avoid invalid analytics values.
1821
percentCompleted = Math.min(100, Math.max(0, rawPercent));
1922
}
23+
return percentCompleted;
24+
}
25+
26+
function getPauseData(videoEl: HTMLVideoElement | MuxElement) {
27+
const currentTime = videoEl.currentTime;
28+
const duration = videoEl.duration;
2029

2130
return {
2231
...getPlayData(videoEl),
2332
last_position: currentTime,
24-
percent_completed: percentCompleted,
33+
percent_completed: calculatePercentCompleted(currentTime, duration),
2534
};
2635
}
2736

@@ -107,8 +116,96 @@ export function trackMuxHtmlVideo(
107116
return trackHtmlVideo(videoEl, handlers, customMetadata, 'mux');
108117
}
109118

110-
export function trackMuxEmbeddedVideo() {
111-
throw new Error('Not implemented');
119+
async function getMuxIframeMetadata(player: MuxEmbeddedPlayer, elem: HTMLIFrameElement) {
120+
const [duration, currentTime] = await Promise.all([
121+
new Promise<number>((resolve) => player.getDuration(resolve)),
122+
new Promise<number>((resolve) => player.getCurrentTime(resolve)),
123+
]);
124+
125+
let url,
126+
metadataVideoTitle = null,
127+
metadataVideoId = null,
128+
playerId = null;
129+
try {
130+
url = new URL(elem.getAttribute('src') as string);
131+
metadataVideoTitle = url.searchParams.get('metadata-video-title');
132+
metadataVideoId = url.searchParams.get('metadata-video-id');
133+
playerId = url.pathname.split('/').pop();
134+
} catch (error) {
135+
// invalid or no src url, skip the header metadata
136+
}
137+
return {
138+
percent_completed: calculatePercentCompleted(currentTime, duration),
139+
program_duration: duration,
140+
last_position: currentTime,
141+
mux_video_title: metadataVideoTitle,
142+
mux_video_id: metadataVideoId,
143+
mux_playback_id: playerId,
144+
};
145+
}
146+
147+
export function trackMuxEmbeddedVideo(
148+
player: MuxEmbeddedPlayer,
149+
handlers: VideoHandler,
150+
customMetadata: Record<string, string | number | boolean>,
151+
) {
152+
const onUnsubscribe: (() => void)[] = [];
153+
const readyHandler = () => {
154+
const { elem } = player;
155+
const playHandler = () => {
156+
getMuxIframeMetadata(player, elem)
157+
.then((playerState) => {
158+
const startEvent: StartVideoEvent = {
159+
...playerState,
160+
...customMetadata,
161+
};
162+
handlers.onPlay(startEvent);
163+
})
164+
.catch((error) => {
165+
handlers.onError(`Error getting Mux iframe metadata from 'play' handler: ${error as string}`);
166+
});
167+
};
168+
player.on('play', playHandler);
169+
onUnsubscribe.push(() => player.off('play', playHandler));
170+
171+
const pauseHandler = () => {
172+
getMuxIframeMetadata(player, elem)
173+
.then((playerState) => {
174+
const pauseEvent: PauseVideoEvent = {
175+
...playerState,
176+
...customMetadata,
177+
};
178+
handlers.onPause(pauseEvent);
179+
})
180+
.catch((error) => {
181+
handlers.onError(`Error getting Mux iframe metadata from 'pause' handler: ${error as string}`);
182+
});
183+
};
184+
player.on('pause', pauseHandler);
185+
onUnsubscribe.push(() => player.off('pause', pauseHandler));
186+
187+
const endedHandler = () => {
188+
getMuxIframeMetadata(player, elem)
189+
.then((playerState) => {
190+
const endedEvent: EndedVideoEvent = {
191+
...playerState,
192+
...customMetadata,
193+
};
194+
handlers.onEnded(endedEvent);
195+
})
196+
.catch((error) => {
197+
handlers.onError(`Error getting Mux iframe metadata from 'ended' handler: ${error as string}`);
198+
});
199+
};
200+
player.on('ended', endedHandler);
201+
onUnsubscribe.push(() => player.off('ended', endedHandler));
202+
};
203+
player.on('ready', readyHandler);
204+
205+
return () => {
206+
player.off('ready', readyHandler);
207+
onUnsubscribe.forEach((unsubscribe) => unsubscribe());
208+
};
112209
}
113210

114211
export function trackYoutubeEmbeddedVideo() {

packages/analytics-core/src/video-analytics/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ type VideoHandler = {
22
onPlay: (startEvent: StartVideoEvent) => void;
33
onPause: (pauseEvent: PauseVideoEvent) => void;
44
onEnded: (endedEvent: EndedVideoEvent) => void;
5+
onError: (error: string) => void;
56
};
67

78
type BaseVideoEvent = {
@@ -30,6 +31,14 @@ type PauseVideoEvent = BaseVideoEvent &
3031
percent_completed: number;
3132
};
3233

34+
type MuxEmbeddedPlayer = {
35+
getCurrentTime: (cb: (time: number) => void) => void;
36+
getDuration: (cb: (duration: number) => void) => void;
37+
on: (event: string, callback: () => void) => void;
38+
off: (event: string, callback: () => void) => void;
39+
elem: HTMLIFrameElement;
40+
};
41+
3342
type EndedVideoEvent = PauseVideoEvent; // & { ... }
3443

3544
type MuxElement = EventTarget &
@@ -43,4 +52,5 @@ export {
4352
PauseVideoEvent,
4453
EndedVideoEvent,
4554
MuxElement,
55+
MuxEmbeddedPlayer,
4656
};

packages/analytics-core/test/video-analytics/mock-video.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,77 @@
1-
import type { VideoHandler } from '../../src/video-analytics/types';
1+
import type { MuxEmbeddedPlayer, VideoHandler } from '../../src/video-analytics/types';
2+
3+
export type MockMuxEmbeddedPlayer = MuxEmbeddedPlayer & {
4+
emit: (event: string) => void;
5+
setCurrentTime: (time: number) => void;
6+
};
7+
8+
/**
9+
* Minimal player.js-style mock for {@link trackMuxEmbeddedVideo} tests.
10+
*/
11+
export function createMockMuxEmbeddedPlayer(options?: {
12+
playbackId?: string;
13+
metadataVideoTitle?: string;
14+
metadataVideoId?: string;
15+
}): { player: MockMuxEmbeddedPlayer } {
16+
const playbackId = options?.playbackId ?? 'dE02GfTAlJD4RcqNAlgiS2m00LqbdFqlBm';
17+
const metadataVideoTitle = options?.metadataVideoTitle ?? 'My Video';
18+
const metadataVideoId = options?.metadataVideoId ?? 'video-123';
19+
20+
const params = new URLSearchParams({
21+
'metadata-video-title': metadataVideoTitle,
22+
'metadata-video-id': metadataVideoId,
23+
});
24+
const iframe = document.createElement('iframe');
25+
iframe.setAttribute('src', `https://player.mux.com/${playbackId}?${params.toString()}`);
26+
27+
const listeners = new Map<string, Set<() => void>>();
28+
let currentTime = 0;
29+
const duration = 10;
30+
31+
const on = (event: string, callback: () => void) => {
32+
let set = listeners.get(event);
33+
if (!set) {
34+
set = new Set();
35+
listeners.set(event, set);
36+
}
37+
set.add(callback);
38+
};
39+
40+
const off = (event: string, callback: () => void) => {
41+
listeners.get(event)?.delete(callback);
42+
};
43+
44+
const emit = (event: string) => {
45+
const cbs = listeners.get(event);
46+
if (cbs) {
47+
[...cbs].forEach((cb) => {
48+
cb();
49+
});
50+
}
51+
};
52+
53+
const getDuration = (cb: (d: number) => void) => {
54+
cb(duration);
55+
};
56+
57+
const getCurrentTime = (cb: (t: number) => void) => {
58+
cb(currentTime);
59+
};
60+
61+
const player: MockMuxEmbeddedPlayer = {
62+
elem: iframe,
63+
on,
64+
off,
65+
getDuration,
66+
getCurrentTime,
67+
emit,
68+
setCurrentTime(t: number) {
69+
currentTime = t;
70+
},
71+
};
72+
73+
return { player };
74+
}
275

376
export function createMockVideo(options: { isMux: boolean } = { isMux: false }): {
477
video: HTMLVideoElement;
@@ -60,6 +133,7 @@ export function createMockVideo(options: { isMux: boolean } = { isMux: false }):
60133
onPlay: jest.fn(),
61134
onPause: jest.fn(),
62135
onEnded: jest.fn(),
136+
onError: jest.fn(),
63137
};
64138

65139
return { video, handler };

packages/analytics-core/test/video-analytics/track-video.test.ts

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
trackYoutubeEmbeddedVideo,
1313
} from '../../src/video-analytics/track-video';
1414
import type { VideoHandler } from '../../src/video-analytics/types';
15-
import { createMockVideo } from './mock-video';
15+
import { createMockMuxEmbeddedPlayer, createMockVideo } from './mock-video';
1616

1717
describe('trackHtmlVideo', () => {
1818
let video: HTMLVideoElement;
@@ -111,8 +111,129 @@ describe('trackMuxHtmlVideo', () => {
111111
});
112112

113113
describe('trackMuxEmbeddedVideo', () => {
114-
test('throws until implemented', () => {
115-
expect(() => trackMuxEmbeddedVideo()).toThrow('Not implemented');
114+
let player: ReturnType<typeof createMockMuxEmbeddedPlayer>['player'];
115+
let handler: VideoHandler;
116+
117+
beforeEach(() => {
118+
jest.useFakeTimers();
119+
({ player } = createMockMuxEmbeddedPlayer());
120+
handler = {
121+
onPlay: jest.fn(),
122+
onPause: jest.fn(),
123+
onEnded: jest.fn(),
124+
onError: jest.fn(),
125+
};
126+
});
127+
128+
afterEach(() => {
129+
jest.useRealTimers();
130+
});
131+
132+
test('should track play, pause and ended events', async () => {
133+
const untrack = trackMuxEmbeddedVideo(player, handler, { hello: 'world' });
134+
135+
player.emit('ready');
136+
137+
const muxMetadata = {
138+
mux_playback_id: 'dE02GfTAlJD4RcqNAlgiS2m00LqbdFqlBm',
139+
mux_video_id: 'video-123',
140+
mux_video_title: 'My Video',
141+
};
142+
143+
player.setCurrentTime(0);
144+
player.emit('play');
145+
await jest.runAllTimersAsync();
146+
expect(handler.onPlay).toHaveBeenCalledWith({
147+
last_position: 0,
148+
percent_completed: 0,
149+
program_duration: 10,
150+
hello: 'world',
151+
...muxMetadata,
152+
});
153+
154+
player.setCurrentTime(5);
155+
player.emit('pause');
156+
await jest.runAllTimersAsync();
157+
expect(handler.onPause).toHaveBeenCalledWith({
158+
last_position: 5,
159+
percent_completed: 50,
160+
program_duration: 10,
161+
hello: 'world',
162+
...muxMetadata,
163+
});
164+
165+
player.setCurrentTime(10);
166+
player.emit('ended');
167+
await jest.runAllTimersAsync();
168+
expect(handler.onEnded).toHaveBeenCalledWith({
169+
last_position: 10,
170+
percent_completed: 100,
171+
program_duration: 10,
172+
hello: 'world',
173+
...muxMetadata,
174+
});
175+
176+
untrack();
177+
handler.onPlay = jest.fn();
178+
player.emit('play');
179+
await jest.runAllTimersAsync();
180+
expect(handler.onPlay).not.toHaveBeenCalled();
181+
});
182+
183+
test('should work when there is no src url', async () => {
184+
player.elem.setAttribute('src', null as unknown as string);
185+
const untrack = trackMuxEmbeddedVideo(player, handler, { foo: 'bar' });
186+
player.emit('ready');
187+
player.emit('play');
188+
await jest.runAllTimersAsync();
189+
expect(handler.onPlay).toHaveBeenCalledWith({
190+
last_position: 0,
191+
program_duration: 10,
192+
percent_completed: 0,
193+
foo: 'bar',
194+
mux_playback_id: null,
195+
mux_video_id: null,
196+
mux_video_title: null,
197+
});
198+
untrack();
199+
handler.onPlay = jest.fn();
200+
player.emit('play');
201+
await jest.runAllTimersAsync();
202+
expect(handler.onPlay).not.toHaveBeenCalled();
203+
});
204+
205+
describe('when there is an error getting the metadata', () => {
206+
let originalGetDuration: typeof player.getDuration;
207+
beforeEach(() => {
208+
originalGetDuration = player.getDuration;
209+
player.getDuration = jest.fn().mockImplementation(() => {
210+
throw new Error('Error getting duration');
211+
});
212+
trackMuxEmbeddedVideo(player, handler, { hello: 'world' });
213+
player.emit('ready');
214+
});
215+
216+
afterEach(() => {
217+
player.getDuration = originalGetDuration;
218+
});
219+
220+
test('should call the error handler (play)', async () => {
221+
player.emit('play');
222+
await jest.runAllTimersAsync();
223+
expect(handler.onError).toHaveBeenCalledTimes(1);
224+
});
225+
226+
test('should call the error handler (pause)', async () => {
227+
player.emit('pause');
228+
await jest.runAllTimersAsync();
229+
expect(handler.onError).toHaveBeenCalledTimes(1);
230+
});
231+
232+
test('should call the error handler (ended)', async () => {
233+
player.emit('ended');
234+
await jest.runAllTimersAsync();
235+
expect(handler.onError).toHaveBeenCalledTimes(1);
236+
});
116237
});
117238
});
118239

0 commit comments

Comments
 (0)