@chunkify/analytics 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +413 -0
  3. package/dist/core.d.ts +144 -0
  4. package/dist/core.d.ts.map +1 -0
  5. package/dist/core.js +660 -0
  6. package/dist/core.js.map +1 -0
  7. package/dist/datadog.d.ts +8 -0
  8. package/dist/datadog.d.ts.map +1 -0
  9. package/dist/datadog.js +15 -0
  10. package/dist/datadog.js.map +1 -0
  11. package/dist/event-types.d.ts +3 -0
  12. package/dist/event-types.d.ts.map +1 -0
  13. package/dist/event-types.js +5 -0
  14. package/dist/event-types.js.map +1 -0
  15. package/dist/hlsjs.d.ts +10 -0
  16. package/dist/hlsjs.d.ts.map +1 -0
  17. package/dist/hlsjs.js +185 -0
  18. package/dist/hlsjs.js.map +1 -0
  19. package/dist/http.d.ts +4 -0
  20. package/dist/http.d.ts.map +1 -0
  21. package/dist/http.js +43 -0
  22. package/dist/http.js.map +1 -0
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +7 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/mapper.d.ts +11 -0
  28. package/dist/mapper.d.ts.map +1 -0
  29. package/dist/mapper.js +11 -0
  30. package/dist/mapper.js.map +1 -0
  31. package/dist/monitor.d.ts +6 -0
  32. package/dist/monitor.d.ts.map +1 -0
  33. package/dist/monitor.js +14 -0
  34. package/dist/monitor.js.map +1 -0
  35. package/dist/options.d.ts +4 -0
  36. package/dist/options.d.ts.map +1 -0
  37. package/dist/options.js +26 -0
  38. package/dist/options.js.map +1 -0
  39. package/dist/otlp.d.ts +4 -0
  40. package/dist/otlp.d.ts.map +1 -0
  41. package/dist/otlp.js +56 -0
  42. package/dist/otlp.js.map +1 -0
  43. package/dist/player.d.ts +8 -0
  44. package/dist/player.d.ts.map +1 -0
  45. package/dist/player.js +93 -0
  46. package/dist/player.js.map +1 -0
  47. package/dist/posthog.d.ts +8 -0
  48. package/dist/posthog.d.ts.map +1 -0
  49. package/dist/posthog.js +15 -0
  50. package/dist/posthog.js.map +1 -0
  51. package/dist/shaka.d.ts +26 -0
  52. package/dist/shaka.d.ts.map +1 -0
  53. package/dist/shaka.js +164 -0
  54. package/dist/shaka.js.map +1 -0
  55. package/dist/videojs.d.ts +11 -0
  56. package/dist/videojs.d.ts.map +1 -0
  57. package/dist/videojs.js +77 -0
  58. package/dist/videojs.js.map +1 -0
  59. package/package.json +75 -0
  60. package/src/core.ts +774 -0
  61. package/src/datadog.ts +24 -0
  62. package/src/event-types.ts +6 -0
  63. package/src/hlsjs.ts +192 -0
  64. package/src/http.ts +43 -0
  65. package/src/index.ts +11 -0
  66. package/src/mapper.ts +22 -0
  67. package/src/monitor.ts +15 -0
  68. package/src/options.ts +28 -0
  69. package/src/otlp.ts +70 -0
  70. package/src/player.ts +96 -0
  71. package/src/posthog.ts +24 -0
  72. package/src/shaka.ts +181 -0
  73. package/src/videojs.ts +89 -0
package/src/core.ts ADDED
@@ -0,0 +1,774 @@
1
+ import { analyticsEventTypes, type AnalyticsEventType } from "./event-types.js";
2
+ import { assertAnalyticsOptions } from "./options.js";
3
+
4
+ export type { AnalyticsEventType } from "./event-types.js";
5
+
6
+ const sdkVersion = "0.1.0"; // x-release-please-version
7
+
8
+ export type AnalyticsEndReason = "ended" | "destroyed" | "source_changed" | "page_exit";
9
+ export type AnalyticsErrorCategory = "aborted" | "network" | "decode" | "unsupported" | "unknown";
10
+ export type AnalyticsStartupOutcome = "started" | "failed_before_start" | "ended_before_start";
11
+ export type AnalyticsPlaybackState = "startup" | "resuming" | "playing" | "paused" | "buffering" |
12
+ "seeking" | "error" | "ended" | "stopped";
13
+ export interface RenditionPlayingTime {
14
+ width: number | null;
15
+ height: number | null;
16
+ codec: string | null;
17
+ bitrate_bps: number | null;
18
+ frame_rate: number | null;
19
+ playing_time_ms: number;
20
+ }
21
+ export type AnalyticsRendition = Omit<RenditionPlayingTime, "playing_time_ms">;
22
+ export interface ViewMetrics {
23
+ media_duration_seconds: number | null;
24
+ startup_time_ms: number | null;
25
+ first_video_frame_time_ms: number | null;
26
+ playing_time_ms: number;
27
+ rebuffer_count: number;
28
+ rebuffer_duration_ms: number;
29
+ rebuffer_ratio: number | null;
30
+ seek_count: number;
31
+ seek_duration_ms: number;
32
+ maximum_playhead_seconds: number;
33
+ furthest_progress_ratio: number | null;
34
+ video_intrinsic_width_css_px: number | null;
35
+ video_intrinsic_height_css_px: number | null;
36
+ dropped_video_frames: number | null;
37
+ total_video_frames: number | null;
38
+ selected_rendition_width: number | null;
39
+ selected_rendition_height: number | null;
40
+ selected_rendition_codec: string | null;
41
+ selected_rendition_bitrate_bps: number | null;
42
+ rendition_switch_count: number | null;
43
+ rendition_playing_time: RenditionPlayingTime[] | null;
44
+ delivery_request_count: number | null;
45
+ delivery_transferred_bytes: number | null;
46
+ delivery_throughput_bps: number | null;
47
+ }
48
+ export interface AnalyticsEventEnvelope {
49
+ schema_version: 1;
50
+ event_name: `chunkify.video.${AnalyticsEventType}`;
51
+ event_id: string;
52
+ sequence: number;
53
+ occurred_at: string;
54
+ view_id: string;
55
+ video_id: string;
56
+ sdk_name: "@chunkify/analytics";
57
+ sdk_version: string;
58
+ connector_name: string;
59
+ connector_version: string | null;
60
+ player_name: string | null;
61
+ player_version: string | null;
62
+ delivery_hostname: string | null;
63
+ environment: string | null;
64
+ viewer_id: string | null;
65
+ reason: AnalyticsEndReason | null;
66
+ startup_outcome: AnalyticsStartupOutcome | null;
67
+ error_category: AnalyticsErrorCategory | null;
68
+ error_code: string | number | null;
69
+ error_fatal: boolean | null;
70
+ error_playhead_seconds: number | null;
71
+ }
72
+ export interface AnalyticsViewEvent extends AnalyticsEventEnvelope, ViewMetrics {
73
+ event_name: "chunkify.video.view_started" | "chunkify.video.view_snapshot" |
74
+ "chunkify.video.playback_error" | "chunkify.video.view_ended";
75
+ }
76
+ export interface AnalyticsPlaybackStateEvent extends AnalyticsEventEnvelope, Partial<ViewMetrics> {
77
+ event_name: "chunkify.video.playback_state_changed";
78
+ playhead_seconds: number | null;
79
+ from_state: AnalyticsPlaybackState;
80
+ to_state: AnalyticsPlaybackState;
81
+ previous_state_duration_ms: number;
82
+ }
83
+ export interface AnalyticsRenditionEvent extends AnalyticsEventEnvelope, Partial<ViewMetrics> {
84
+ event_name: "chunkify.video.rendition_changed";
85
+ playhead_seconds: number | null;
86
+ previous_rendition_width: number | null;
87
+ previous_rendition_height: number | null;
88
+ previous_rendition_codec: string | null;
89
+ previous_rendition_bitrate_bps: number | null;
90
+ previous_rendition_frame_rate: number | null;
91
+ selected_rendition_width: number | null;
92
+ selected_rendition_height: number | null;
93
+ selected_rendition_codec: string | null;
94
+ selected_rendition_bitrate_bps: number | null;
95
+ selected_rendition_frame_rate: number | null;
96
+ }
97
+ export type ChunkifyPlayerAnalyticsEvent = AnalyticsViewEvent | AnalyticsPlaybackStateEvent | AnalyticsRenditionEvent;
98
+ export interface AnalyticsDestination {
99
+ send(event: ChunkifyPlayerAnalyticsEvent): void | Promise<void>;
100
+ }
101
+ export interface AnalyticsConfig {
102
+ destinations: readonly AnalyticsDestination[];
103
+ environment?: string | undefined;
104
+ viewerId?: string | undefined;
105
+ /** Active playing time between cumulative snapshots. Defaults to 30,000 ms. */
106
+ snapshotIntervalMs?: number | undefined;
107
+ /** Event types passed to destinations. Defaults to all six types. */
108
+ includeEvents?: readonly AnalyticsEventType[] | undefined;
109
+ }
110
+ export interface AnalyticsOptions extends AnalyticsConfig {
111
+ videoId: string;
112
+ }
113
+ export interface AnalyticsConnectorSnapshot {
114
+ renditionWidth: number | null;
115
+ renditionHeight: number | null;
116
+ renditionCodec: string | null;
117
+ renditionBitrate: number | null;
118
+ renditionSwitchCount: number | null;
119
+ deliveryRequestCount: number | null;
120
+ deliveryTransferredBytes: number | null;
121
+ deliveryDurationMs: number | null;
122
+ }
123
+ export interface AnalyticsConnectorError {
124
+ category: AnalyticsErrorCategory;
125
+ code: string | number | null;
126
+ fatal: boolean;
127
+ }
128
+ export interface AnalyticsConnector {
129
+ name: string;
130
+ version: string | null;
131
+ playerName: string | null;
132
+ playerVersion: string | null;
133
+ snapshot(): AnalyticsConnectorSnapshot;
134
+ subscribeToErrors(listener: (error: AnalyticsConnectorError) => void): () => void;
135
+ /** Fires with the rendition currently reaching playback, including the initial selection. */
136
+ subscribeToRenditions?(listener: (rendition: AnalyticsRendition | null) => void): () => void;
137
+ }
138
+ export interface AnalyticsSession {
139
+ /** Call immediately before play() to include attempts that reject before a play event. */
140
+ startAttempt(): void;
141
+ /** Finish the current view before its media source or playback engine is replaced. */
142
+ sourceChanged(): void;
143
+ /** Add or replace optional engine-specific measurements for subsequent records. */
144
+ setConnector(connector: AnalyticsConnector | null): void;
145
+ /** End the current view, if any, and remove all analytics listeners. Idempotent. */
146
+ destroy(): void;
147
+ }
148
+
149
+ interface View {
150
+ id: string;
151
+ sequence: number;
152
+ startedAt: number;
153
+ startup: number | null;
154
+ firstVideoFrame: number | null;
155
+ frameCallbackId: number | null;
156
+ playing: number;
157
+ playingSince: number | null;
158
+ rebufferCount: number;
159
+ rebufferDuration: number;
160
+ rebufferSince: number | null;
161
+ seekCount: number;
162
+ seekDuration: number;
163
+ seekSince: number | null;
164
+ maximumPlayhead: number;
165
+ nextSnapshotAt: number;
166
+ duration: number | null;
167
+ intrinsicVideoWidth: number | null;
168
+ intrinsicVideoHeight: number | null;
169
+ hostname: string | null;
170
+ source: string;
171
+ resourceStarted: boolean;
172
+ errorEmitted: boolean;
173
+ fatalErrorBeforeStart: boolean;
174
+ frameBaseline: FrameCounts | null;
175
+ framePrevious: FrameCounts | null;
176
+ frameMetricsValid: boolean;
177
+ connectorBaseline: AnalyticsConnectorSnapshot | null;
178
+ renditionTrackingAvailable: boolean;
179
+ renditionTimes: Map<string, RenditionPlayingTime>;
180
+ activeRendition: AnalyticsRendition | null;
181
+ activeRenditionSince: number | null;
182
+ playbackState: AnalyticsPlaybackState;
183
+ playbackStateSince: number;
184
+ }
185
+
186
+ interface FrameCounts {
187
+ dropped: number;
188
+ total: number;
189
+ }
190
+
191
+ interface NormalizedError {
192
+ category: AnalyticsErrorCategory;
193
+ code: string | number | null;
194
+ fatal: boolean;
195
+ playhead: number | null;
196
+ }
197
+
198
+ function finiteNonNegative(value: number | null): number | null {
199
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
200
+ }
201
+
202
+ function nonNegativeDelta(current: number | null, baseline: number | null): number | null {
203
+ return current === null || baseline === null ? null : finiteNonNegative(current - baseline);
204
+ }
205
+
206
+ function finitePositive(value: unknown): number | null {
207
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
208
+ }
209
+
210
+ function intrinsicVideoSize(media: HTMLMediaElement): { width: number | null; height: number | null } {
211
+ try {
212
+ const customMedia = media as HTMLMediaElement & { nativeEl?: Element | null };
213
+ const video = (customMedia.nativeEl?.localName === "video" ? customMedia.nativeEl : media) as
214
+ HTMLMediaElement & Partial<Pick<HTMLVideoElement, "videoWidth" | "videoHeight">>;
215
+ const width = finitePositive(video.videoWidth);
216
+ const height = finitePositive(video.videoHeight);
217
+ return width !== null && height !== null ? { width, height } : { width: null, height: null };
218
+ } catch {
219
+ return { width: null, height: null };
220
+ }
221
+ }
222
+
223
+ function normalizedRendition(rendition: AnalyticsRendition | null): AnalyticsRendition | null {
224
+ if (!rendition) return null;
225
+ const result = {
226
+ width: finiteNonNegative(rendition.width),
227
+ height: finiteNonNegative(rendition.height),
228
+ codec: typeof rendition.codec === "string" && rendition.codec ? rendition.codec : null,
229
+ bitrate_bps: finiteNonNegative(rendition.bitrate_bps),
230
+ frame_rate: finiteNonNegative(rendition.frame_rate),
231
+ };
232
+ return Object.values(result).every((value) => value === null) ? null : result;
233
+ }
234
+
235
+ function renditionKey(rendition: AnalyticsRendition): string {
236
+ return JSON.stringify([rendition.width, rendition.height, rendition.codec, rendition.bitrate_bps, rendition.frame_rate]);
237
+ }
238
+
239
+ function renditionPlayingTime(current: View, now: number): RenditionPlayingTime[] | null {
240
+ if (!current.renditionTrackingAvailable) return null;
241
+ const times = new Map([...current.renditionTimes].map(([key, value]) => [key, { ...value }]));
242
+ if (current.activeRendition && current.activeRenditionSince !== null) {
243
+ const key = renditionKey(current.activeRendition);
244
+ const entry = times.get(key) ?? { ...current.activeRendition, playing_time_ms: 0 };
245
+ entry.playing_time_ms += Math.max(0, now - current.activeRenditionSince);
246
+ times.set(key, entry);
247
+ }
248
+ return [...times.values()];
249
+ }
250
+
251
+ const emptyConnectorMetrics = {
252
+ selected_rendition_width: null,
253
+ selected_rendition_height: null,
254
+ selected_rendition_codec: null,
255
+ selected_rendition_bitrate_bps: null,
256
+ rendition_switch_count: null,
257
+ delivery_request_count: null,
258
+ delivery_transferred_bytes: null,
259
+ delivery_throughput_bps: null,
260
+ };
261
+
262
+ function connectorMetrics(connector: AnalyticsConnector | null, baseline: AnalyticsConnectorSnapshot | null): Pick<ViewMetrics,
263
+ "selected_rendition_width" | "selected_rendition_height" | "selected_rendition_codec" |
264
+ "selected_rendition_bitrate_bps" | "rendition_switch_count" | "delivery_request_count" |
265
+ "delivery_transferred_bytes" | "delivery_throughput_bps"> {
266
+ if (!connector || !baseline) return { ...emptyConnectorMetrics };
267
+ let current: AnalyticsConnectorSnapshot;
268
+ try {
269
+ current = connector.snapshot();
270
+ } catch {
271
+ return { ...emptyConnectorMetrics };
272
+ }
273
+ const switches = nonNegativeDelta(current.renditionSwitchCount, baseline.renditionSwitchCount);
274
+ const requests = nonNegativeDelta(current.deliveryRequestCount, baseline.deliveryRequestCount);
275
+ const bytes = nonNegativeDelta(current.deliveryTransferredBytes, baseline.deliveryTransferredBytes);
276
+ const deliveryDuration = nonNegativeDelta(current.deliveryDurationMs, baseline.deliveryDurationMs);
277
+ return {
278
+ selected_rendition_width: finiteNonNegative(current.renditionWidth),
279
+ selected_rendition_height: finiteNonNegative(current.renditionHeight),
280
+ selected_rendition_codec: typeof current.renditionCodec === "string" && current.renditionCodec ? current.renditionCodec : null,
281
+ selected_rendition_bitrate_bps: finiteNonNegative(current.renditionBitrate),
282
+ rendition_switch_count: switches,
283
+ delivery_request_count: requests,
284
+ delivery_transferred_bytes: bytes,
285
+ delivery_throughput_bps: bytes !== null && deliveryDuration !== null && deliveryDuration > 0
286
+ ? bytes * 8 * 1000 / deliveryDuration : null,
287
+ };
288
+ }
289
+
290
+ function deliveryHostname(media: HTMLMediaElement): string | null {
291
+ try {
292
+ const url = new URL(media.currentSrc || media.src);
293
+ return url.protocol === "http:" || url.protocol === "https:" ? url.hostname : null;
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+ function duration(media: HTMLMediaElement): number | null {
299
+ return Number.isFinite(media.duration) && media.duration >= 0 ? media.duration : null;
300
+ }
301
+ function mediaErrorCategory(code: number | null): AnalyticsErrorCategory {
302
+ return code === 1 ? "aborted" : code === 2 ? "network" : code === 3 ? "decode"
303
+ : code === 4 ? "unsupported" : "unknown";
304
+ }
305
+ function frameCounts(media: HTMLMediaElement): FrameCounts | null {
306
+ const video = media as HTMLMediaElement & Partial<Pick<HTMLVideoElement, "getVideoPlaybackQuality">>;
307
+ if (typeof video.getVideoPlaybackQuality === "function") {
308
+ try {
309
+ const quality = video.getVideoPlaybackQuality();
310
+ if (Number.isInteger(quality.droppedVideoFrames) && quality.droppedVideoFrames >= 0 &&
311
+ Number.isInteger(quality.totalVideoFrames) && quality.totalVideoFrames >= 0) {
312
+ return { dropped: quality.droppedVideoFrames, total: quality.totalVideoFrames };
313
+ }
314
+ } catch {
315
+ // Treat an inaccessible browser counter like an unavailable measurement.
316
+ }
317
+ }
318
+ const webkit = media as HTMLMediaElement & {
319
+ webkitDecodedFrameCount?: number;
320
+ webkitDroppedFrameCount?: number;
321
+ };
322
+ if (!Number.isInteger(webkit.webkitDecodedFrameCount) || (webkit.webkitDecodedFrameCount ?? -1) < 0 ||
323
+ !Number.isInteger(webkit.webkitDroppedFrameCount) || (webkit.webkitDroppedFrameCount ?? -1) < 0) return null;
324
+ return { dropped: webkit.webkitDroppedFrameCount!, total: webkit.webkitDecodedFrameCount! };
325
+ }
326
+
327
+ export function attachAnalytics(media: HTMLMediaElement, options: AnalyticsOptions): AnalyticsSession {
328
+ return createAnalyticsSession(media, options, false);
329
+ }
330
+
331
+ /** Engine adapters call this when they own source-change events. */
332
+ export function attachAnalyticsWithManagedSource(media: HTMLMediaElement, options: AnalyticsOptions): AnalyticsSession {
333
+ return createAnalyticsSession(media, options, true);
334
+ }
335
+
336
+ function createAnalyticsSession(media: HTMLMediaElement, options: AnalyticsOptions,
337
+ sourceManagedByEngine: boolean): AnalyticsSession {
338
+ assertAnalyticsOptions(options);
339
+ const frameVideo = media as HTMLMediaElement & Partial<Pick<HTMLVideoElement,
340
+ "requestVideoFrameCallback" | "cancelVideoFrameCallback">>;
341
+ const { videoId, environment = null, viewerId = null, snapshotIntervalMs = 30000 } = options;
342
+ const destinations = [...options.destinations];
343
+ const includedEvents = new Set((options.includeEvents ?? analyticsEventTypes)
344
+ .map((event) => `chunkify.video.${event}`));
345
+ let view: View | null = null;
346
+ let destroyed = false;
347
+ let wantsPlay = false;
348
+ let seeking = false;
349
+ let snapshotTimer: ReturnType<typeof setTimeout> | null = null;
350
+ let connector: AnalyticsConnector | null = null;
351
+ let unsubscribeConnector: (() => void) | null = null;
352
+ let unsubscribeRenditions: (() => void) | null = null;
353
+ let selectedRendition: AnalyticsRendition | null = null;
354
+
355
+ function refreshIntrinsicVideoSize(current: View) {
356
+ const size = intrinsicVideoSize(media);
357
+ if (size.width === null || size.height === null) return;
358
+ current.intrinsicVideoWidth = size.width;
359
+ current.intrinsicVideoHeight = size.height;
360
+ }
361
+
362
+ function cancelFirstVideoFrame(current: View) {
363
+ if (current.frameCallbackId === null) return;
364
+ const callbackId = current.frameCallbackId;
365
+ current.frameCallbackId = null;
366
+ try { frameVideo.cancelVideoFrameCallback?.(callbackId); } catch {}
367
+ }
368
+
369
+ function observeFirstVideoFrame(current: View) {
370
+ if (typeof frameVideo.requestVideoFrameCallback !== "function" ||
371
+ typeof frameVideo.cancelVideoFrameCallback !== "function") return;
372
+ try {
373
+ current.frameCallbackId = frameVideo.requestVideoFrameCallback((observedAt) => {
374
+ if (current.frameCallbackId === null) return;
375
+ current.frameCallbackId = null;
376
+ if (view !== current) return;
377
+ // WebKit can report a presentationTime outside performance.now()'s clock.
378
+ if (!Number.isFinite(observedAt) || observedAt < current.startedAt) return;
379
+ current.firstVideoFrame = observedAt - current.startedAt;
380
+ });
381
+ } catch {
382
+ // Frame timing is optional; an inaccessible browser API leaves it unavailable.
383
+ }
384
+ }
385
+
386
+ function closeActiveRendition(now: number) {
387
+ if (!view || !view.activeRendition || view.activeRenditionSince === null) return;
388
+ const key = renditionKey(view.activeRendition);
389
+ const entry = view.renditionTimes.get(key) ?? { ...view.activeRendition, playing_time_ms: 0 };
390
+ entry.playing_time_ms += Math.max(0, now - view.activeRenditionSince);
391
+ view.renditionTimes.set(key, entry);
392
+ view.activeRendition = null;
393
+ view.activeRenditionSince = null;
394
+ }
395
+
396
+ function onRenditionChanged(rendition: AnalyticsRendition | null) {
397
+ const next = normalizedRendition(rendition);
398
+ const previous = selectedRendition;
399
+ if (previous && next && renditionKey(previous) === renditionKey(next)) return;
400
+ const now = performance.now();
401
+ closeActiveRendition(now);
402
+ selectedRendition = next;
403
+ if (view && view.playingSince !== null && selectedRendition) {
404
+ view.activeRendition = selectedRendition;
405
+ view.activeRenditionSince = now;
406
+ }
407
+ if (view && previous && next) emitRenditionChange(view, previous, next);
408
+ }
409
+
410
+ function playheadSeconds(): number | null {
411
+ return Number.isFinite(media.currentTime) && media.currentTime >= 0 ? media.currentTime : null;
412
+ }
413
+
414
+ function envelope(current: View, event_name: ChunkifyPlayerAnalyticsEvent["event_name"]): AnalyticsEventEnvelope {
415
+ return {
416
+ schema_version: 1, event_name, event_id: crypto.randomUUID(), sequence: ++current.sequence,
417
+ occurred_at: new Date().toISOString(), view_id: current.id, video_id: videoId,
418
+ sdk_name: "@chunkify/analytics", sdk_version: sdkVersion,
419
+ connector_name: connector?.name ?? "html-media", connector_version: connector?.version ?? sdkVersion,
420
+ player_name: connector?.playerName ?? null, player_version: connector?.playerVersion ?? null,
421
+ delivery_hostname: current.hostname, environment, viewer_id: viewerId,
422
+ reason: null, startup_outcome: null, error_category: null, error_code: null,
423
+ error_fatal: null, error_playhead_seconds: null,
424
+ };
425
+ }
426
+
427
+ function deliver(event: ChunkifyPlayerAnalyticsEvent) {
428
+ for (const destination of destinations) {
429
+ try {
430
+ // A failing destination must not interrupt playback or another destination.
431
+ void Promise.resolve(destination.send(event)).catch(() => {});
432
+ } catch {
433
+ // Synchronous delivery failures are isolated for the same reason.
434
+ }
435
+ }
436
+ }
437
+
438
+ function changePlaybackState(next: AnalyticsPlaybackState, current = view) {
439
+ if (!current || current.playbackState === next) return;
440
+ if (current.playbackState === "ended" || current.playbackState === "stopped" ||
441
+ (current.playbackState === "error" && next !== "ended" && next !== "stopped")) return;
442
+ const now = performance.now();
443
+ const previous = current.playbackState;
444
+ const elapsed = Math.max(0, now - current.playbackStateSince);
445
+ current.playbackState = next;
446
+ current.playbackStateSince = now;
447
+ if (!includedEvents.has("chunkify.video.playback_state_changed")) return;
448
+ deliver({ ...envelope(current, "chunkify.video.playback_state_changed"),
449
+ event_name: "chunkify.video.playback_state_changed", playhead_seconds: playheadSeconds(),
450
+ from_state: previous, to_state: next, previous_state_duration_ms: elapsed });
451
+ }
452
+
453
+ function emitRenditionChange(current: View, previous: AnalyticsRendition, selected: AnalyticsRendition) {
454
+ if (!includedEvents.has("chunkify.video.rendition_changed")) return;
455
+ deliver({ ...envelope(current, "chunkify.video.rendition_changed"),
456
+ event_name: "chunkify.video.rendition_changed", playhead_seconds: playheadSeconds(),
457
+ previous_rendition_width: previous.width, previous_rendition_height: previous.height,
458
+ previous_rendition_codec: previous.codec, previous_rendition_bitrate_bps: previous.bitrate_bps,
459
+ previous_rendition_frame_rate: previous.frame_rate,
460
+ selected_rendition_width: selected.width, selected_rendition_height: selected.height,
461
+ selected_rendition_codec: selected.codec, selected_rendition_bitrate_bps: selected.bitrate_bps,
462
+ selected_rendition_frame_rate: selected.frame_rate });
463
+ }
464
+
465
+ function emit(current: View, event_name: AnalyticsViewEvent["event_name"], reason: AnalyticsEndReason | null = null,
466
+ error: NormalizedError | null = null) {
467
+ if (!includedEvents.has(event_name)) return;
468
+ const now = performance.now();
469
+ const playingTime = current.playing + (current.playingSince === null ? 0 : now - current.playingSince);
470
+ const rebufferDuration = current.rebufferDuration + (current.rebufferSince === null ? 0 : now - current.rebufferSince);
471
+ const seekDuration = current.seekDuration + (current.seekSince === null ? 0 : now - current.seekSince);
472
+ const rebufferDenominator = playingTime + rebufferDuration;
473
+ const frames = frameCounts(media);
474
+ let viewFrames: FrameCounts | null = null;
475
+ if (frames && current.frameBaseline && current.framePrevious && current.frameMetricsValid) {
476
+ if (frames.dropped < current.framePrevious.dropped || frames.total < current.framePrevious.total) {
477
+ current.frameMetricsValid = false;
478
+ } else {
479
+ viewFrames = {
480
+ dropped: frames.dropped - current.frameBaseline.dropped,
481
+ total: frames.total - current.frameBaseline.total,
482
+ };
483
+ }
484
+ }
485
+ if (frames) current.framePrevious = frames;
486
+ const engineMetrics = connectorMetrics(connector, current.connectorBaseline);
487
+ let startupOutcome: AnalyticsStartupOutcome | null = null;
488
+ if (event_name === "chunkify.video.view_ended") {
489
+ if (current.startup !== null) startupOutcome = "started";
490
+ else if (current.fatalErrorBeforeStart) startupOutcome = "failed_before_start";
491
+ else startupOutcome = "ended_before_start";
492
+ }
493
+ refreshIntrinsicVideoSize(current);
494
+ const event: AnalyticsViewEvent = {
495
+ ...envelope(current, event_name), event_name, reason, startup_outcome: startupOutcome,
496
+ error_category: error?.category ?? null,
497
+ error_code: error?.code ?? null,
498
+ error_fatal: error?.fatal ?? null,
499
+ error_playhead_seconds: error?.playhead ?? null,
500
+ media_duration_seconds: current.duration,
501
+ startup_time_ms: current.startup,
502
+ first_video_frame_time_ms: current.firstVideoFrame,
503
+ playing_time_ms: playingTime,
504
+ rebuffer_count: current.rebufferCount,
505
+ rebuffer_duration_ms: rebufferDuration,
506
+ rebuffer_ratio: rebufferDenominator > 0 ? rebufferDuration / rebufferDenominator : null,
507
+ seek_count: current.seekCount,
508
+ seek_duration_ms: seekDuration,
509
+ maximum_playhead_seconds: current.maximumPlayhead,
510
+ furthest_progress_ratio: current.duration === null || current.duration === 0 ? null : Math.min(1, current.maximumPlayhead / current.duration),
511
+ video_intrinsic_width_css_px: current.intrinsicVideoWidth,
512
+ video_intrinsic_height_css_px: current.intrinsicVideoHeight,
513
+ dropped_video_frames: viewFrames?.dropped ?? null,
514
+ total_video_frames: viewFrames?.total ?? null,
515
+ ...engineMetrics,
516
+ rendition_playing_time: renditionPlayingTime(current, now),
517
+ };
518
+ deliver(event);
519
+ }
520
+ function startAttempt() {
521
+ if (destroyed) return;
522
+ wantsPlay = true;
523
+ if (view) {
524
+ if (view.playbackState === "paused") changePlaybackState("resuming");
525
+ return;
526
+ }
527
+ const initialFrames = frameCounts(media);
528
+ const initialVideoSize = intrinsicVideoSize(media);
529
+ const startedAt = performance.now();
530
+ const current: View = { id: crypto.randomUUID(), sequence: 0, startedAt,
531
+ startup: null, firstVideoFrame: null, frameCallbackId: null,
532
+ playing: 0, playingSince: null,
533
+ rebufferCount: 0, rebufferDuration: 0, rebufferSince: null,
534
+ seekCount: 0, seekDuration: 0, seekSince: null,
535
+ maximumPlayhead: Number.isFinite(media.currentTime) ? Math.max(0, media.currentTime) : 0,
536
+ nextSnapshotAt: snapshotIntervalMs,
537
+ duration: duration(media),
538
+ intrinsicVideoWidth: initialVideoSize.width, intrinsicVideoHeight: initialVideoSize.height,
539
+ hostname: deliveryHostname(media), source: media.src || media.currentSrc,
540
+ // NETWORK_IDLE (1) and NETWORK_LOADING (2) already have a selected resource.
541
+ resourceStarted: media.readyState > 0 || media.networkState === 1 || media.networkState === 2,
542
+ errorEmitted: false, fatalErrorBeforeStart: false,
543
+ frameBaseline: initialFrames, framePrevious: initialFrames,
544
+ frameMetricsValid: initialFrames !== null,
545
+ connectorBaseline: connector ? safeConnectorSnapshot(connector) : null,
546
+ renditionTrackingAvailable: Boolean(connector?.subscribeToRenditions),
547
+ renditionTimes: new Map(), activeRendition: null, activeRenditionSince: null,
548
+ playbackState: "startup", playbackStateSince: startedAt };
549
+ view = current;
550
+ if (includedEvents.size > 0) observeFirstVideoFrame(current);
551
+ emit(current, "chunkify.video.view_started");
552
+ if (view !== current) return;
553
+ if (media.error) reportMediaError();
554
+ }
555
+ function stopPlaying() {
556
+ if (snapshotTimer !== null) {
557
+ clearTimeout(snapshotTimer);
558
+ snapshotTimer = null;
559
+ }
560
+ if (!view || view.playingSince === null) return;
561
+ const now = performance.now();
562
+ closeActiveRendition(now);
563
+ view.playing += now - view.playingSince;
564
+ view.playingSince = null;
565
+ }
566
+ function stopRebuffering() {
567
+ if (!view || view.rebufferSince === null) return;
568
+ view.rebufferDuration += performance.now() - view.rebufferSince;
569
+ view.rebufferSince = null;
570
+ }
571
+ function stopSeeking() {
572
+ if (!view || view.seekSince === null) return;
573
+ view.seekDuration += performance.now() - view.seekSince;
574
+ view.seekSince = null;
575
+ }
576
+ function updatePlaybackPosition() {
577
+ if (!view) return;
578
+ refreshIntrinsicVideoSize(view);
579
+ if (!Number.isFinite(media.currentTime)) return;
580
+ view.maximumPlayhead = Math.max(view.maximumPlayhead, media.currentTime);
581
+ }
582
+ function reportMediaError() {
583
+ if (!view) return;
584
+ const current = view;
585
+ cancelFirstVideoFrame(current);
586
+ if (current.startup === null) current.fatalErrorBeforeStart = true;
587
+ if (current.errorEmitted) return;
588
+ updatePlaybackPosition();
589
+ current.errorEmitted = true;
590
+ const code = media.error && Number.isFinite(media.error.code) ? media.error.code : null;
591
+ emit(current, "chunkify.video.playback_error", null, {
592
+ category: mediaErrorCategory(code), code, fatal: true,
593
+ playhead: Number.isFinite(media.currentTime) ? Math.max(0, media.currentTime) : null,
594
+ });
595
+ if (view === current) changePlaybackState("error");
596
+ }
597
+ function reportConnectorError(error: AnalyticsConnectorError) {
598
+ if (!view) return;
599
+ const current = view;
600
+ if (error.fatal) cancelFirstVideoFrame(current);
601
+ if (error.fatal && current.startup === null) current.fatalErrorBeforeStart = true;
602
+ if (current.errorEmitted) return;
603
+ if (error.fatal) {
604
+ stopPlaying();
605
+ stopRebuffering();
606
+ stopSeeking();
607
+ seeking = false;
608
+ wantsPlay = false;
609
+ }
610
+ updatePlaybackPosition();
611
+ current.errorEmitted = true;
612
+ emit(current, "chunkify.video.playback_error", null, {
613
+ category: error.category,
614
+ code: typeof error.code === "string" || typeof error.code === "number" ? error.code : null,
615
+ fatal: Boolean(error.fatal),
616
+ playhead: Number.isFinite(media.currentTime) ? Math.max(0, media.currentTime) : null,
617
+ });
618
+ if (error.fatal && view === current) changePlaybackState("error");
619
+ }
620
+ function setConnector(next: AnalyticsConnector | null) {
621
+ if (destroyed) return;
622
+ const previousUnsubscribe = unsubscribeConnector;
623
+ const previousRenditionUnsubscribe = unsubscribeRenditions;
624
+ unsubscribeConnector = null;
625
+ unsubscribeRenditions = null;
626
+ try { previousUnsubscribe?.(); } catch {}
627
+ try { previousRenditionUnsubscribe?.(); } catch {}
628
+ if (destroyed) return;
629
+ onRenditionChanged(null);
630
+ connector = next;
631
+ if (view) view.connectorBaseline = connector ? safeConnectorSnapshot(connector) : null;
632
+ if (view && connector?.subscribeToRenditions) view.renditionTrackingAvailable = true;
633
+ if (connector) {
634
+ let unsubscribe: (() => void) | null = null;
635
+ try { unsubscribe = connector.subscribeToErrors(reportConnectorError); } catch {}
636
+ let unsubscribeRendition: (() => void) | null = null;
637
+ try { unsubscribeRendition = connector.subscribeToRenditions?.(onRenditionChanged) ?? null; } catch {}
638
+ if (destroyed) {
639
+ try { unsubscribe?.(); } catch {}
640
+ try { unsubscribeRendition?.(); } catch {}
641
+ return;
642
+ }
643
+ unsubscribeConnector = unsubscribe;
644
+ unsubscribeRenditions = unsubscribeRendition;
645
+ }
646
+ }
647
+ function scheduleSnapshot() {
648
+ if (!view || !includedEvents.has("chunkify.video.view_snapshot") || snapshotTimer !== null) return;
649
+ const playingSince = view.playingSince;
650
+ if (playingSince === null) return;
651
+ const current = view;
652
+ const playingTime = current.playing + performance.now() - playingSince;
653
+ snapshotTimer = globalThis.setTimeout(() => {
654
+ snapshotTimer = null;
655
+ if (view !== current || current.playingSince === null) return;
656
+ updatePlaybackPosition();
657
+ metadata();
658
+ emit(current, "chunkify.video.view_snapshot");
659
+ const currentPlayingTime = current.playing + performance.now() - current.playingSince;
660
+ const nextFutureInterval = (Math.floor(currentPlayingTime / snapshotIntervalMs) + 1) * snapshotIntervalMs;
661
+ current.nextSnapshotAt = Math.max(current.nextSnapshotAt + snapshotIntervalMs, nextFutureInterval);
662
+ if (view === current) scheduleSnapshot();
663
+ }, Math.max(0, current.nextSnapshotAt - playingTime));
664
+ }
665
+ function playing() {
666
+ if (!view || !wantsPlay || seeking) return;
667
+ refreshIntrinsicVideoSize(view);
668
+ const now = performance.now();
669
+ view.resourceStarted = true;
670
+ view.startup ??= now - view.startedAt;
671
+ stopRebuffering();
672
+ view.playingSince ??= now;
673
+ if (view.activeRenditionSince === null && selectedRendition) {
674
+ view.activeRendition = selectedRendition;
675
+ view.activeRenditionSince = now;
676
+ }
677
+ changePlaybackState("playing");
678
+ scheduleSnapshot();
679
+ }
680
+ function finish(reason: AnalyticsEndReason) {
681
+ if (!view) return;
682
+ const current = view;
683
+ cancelFirstVideoFrame(current);
684
+ stopPlaying();
685
+ stopRebuffering();
686
+ stopSeeking();
687
+ updatePlaybackPosition();
688
+ view = null;
689
+ wantsPlay = false;
690
+ seeking = false;
691
+ changePlaybackState(reason === "ended" ? "ended" : "stopped", current);
692
+ emit(current, "chunkify.video.view_ended", reason);
693
+ }
694
+ function metadata() {
695
+ if (!view) return;
696
+ view.duration = duration(media);
697
+ refreshIntrinsicVideoSize(view);
698
+ view.hostname = deliveryHostname(media);
699
+ }
700
+ const listeners: Record<string, () => void> = {
701
+ play: startAttempt, playing,
702
+ waiting() {
703
+ stopPlaying();
704
+ if (view && view.startup !== null && wantsPlay && !seeking && view.rebufferSince === null) {
705
+ view.rebufferCount++;
706
+ view.rebufferSince = performance.now();
707
+ changePlaybackState("buffering");
708
+ }
709
+ },
710
+ error() {
711
+ stopPlaying(); stopRebuffering(); stopSeeking(); seeking = false; wantsPlay = false;
712
+ reportMediaError();
713
+ },
714
+ ended() { finish("ended"); },
715
+ emptied() {
716
+ if (sourceManagedByEngine) return;
717
+ if (!view) return;
718
+ // A new attempt can begin after src changes but before the old resource's
719
+ // queued emptied event arrives. That reset does not belong to the new view.
720
+ if (view.resourceStarted || (view.source && view.source !== (media.src || media.currentSrc))) finish("source_changed");
721
+ },
722
+ loadstart() {
723
+ if (!view) return;
724
+ const source = media.src || media.currentSrc;
725
+ if (!sourceManagedByEngine && view.source && view.source !== source) { finish("source_changed"); return; }
726
+ // <source> selection can first expose the URL after the initial attempt.
727
+ view.source = source;
728
+ view.resourceStarted = true;
729
+ },
730
+ pause() {
731
+ stopPlaying(); stopRebuffering(); wantsPlay = false;
732
+ if (view && !media.ended) changePlaybackState("paused");
733
+ },
734
+ seeking() {
735
+ stopPlaying();
736
+ stopRebuffering();
737
+ updatePlaybackPosition();
738
+ if (view && !seeking) { view.seekCount++; view.seekSince = performance.now(); }
739
+ seeking = true;
740
+ changePlaybackState("seeking");
741
+ },
742
+ seeked() {
743
+ updatePlaybackPosition(); stopSeeking(); seeking = false;
744
+ changePlaybackState(wantsPlay ? "resuming" : "paused");
745
+ },
746
+ timeupdate: updatePlaybackPosition,
747
+ loadedmetadata: metadata, durationchange: metadata,
748
+ };
749
+ for (const [name, listener] of Object.entries(listeners)) media.addEventListener(name, listener);
750
+ const page = media.ownerDocument.defaultView;
751
+ const pagehide = () => finish("page_exit");
752
+ page?.addEventListener("pagehide", pagehide);
753
+ return {
754
+ startAttempt,
755
+ sourceChanged() { finish("source_changed"); },
756
+ setConnector,
757
+ destroy() {
758
+ if (destroyed) return;
759
+ destroyed = true;
760
+ for (const [name, listener] of Object.entries(listeners)) media.removeEventListener(name, listener);
761
+ page?.removeEventListener("pagehide", pagehide);
762
+ finish("destroyed");
763
+ try { unsubscribeConnector?.(); } catch {}
764
+ try { unsubscribeRenditions?.(); } catch {}
765
+ unsubscribeConnector = null;
766
+ unsubscribeRenditions = null;
767
+ connector = null;
768
+ },
769
+ };
770
+ }
771
+
772
+ function safeConnectorSnapshot(connector: AnalyticsConnector): AnalyticsConnectorSnapshot | null {
773
+ try { return connector.snapshot(); } catch { return null; }
774
+ }