@scarlett-player/analytics 0.2.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,609 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createAnalyticsPlugin: () => createAnalyticsPlugin,
24
+ default: () => index_default
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/helpers.ts
29
+ function generateId() {
30
+ const timestamp = Date.now();
31
+ const random = Math.random().toString(36).substring(2, 11);
32
+ return `${timestamp}-${random}`;
33
+ }
34
+ function getSessionId() {
35
+ const STORAGE_KEY = "sp_session_id";
36
+ try {
37
+ let sessionId = sessionStorage.getItem(STORAGE_KEY);
38
+ if (!sessionId) {
39
+ sessionId = generateId();
40
+ sessionStorage.setItem(STORAGE_KEY, sessionId);
41
+ }
42
+ return sessionId;
43
+ } catch (error) {
44
+ return generateId();
45
+ }
46
+ }
47
+ function getAnonymousViewerId() {
48
+ const STORAGE_KEY = "sp_viewer_id";
49
+ try {
50
+ let viewerId = localStorage.getItem(STORAGE_KEY);
51
+ if (!viewerId) {
52
+ viewerId = generateId();
53
+ localStorage.setItem(STORAGE_KEY, viewerId);
54
+ }
55
+ return viewerId;
56
+ } catch (error) {
57
+ try {
58
+ let viewerId = sessionStorage.getItem(STORAGE_KEY);
59
+ if (!viewerId) {
60
+ viewerId = generateId();
61
+ sessionStorage.setItem(STORAGE_KEY, viewerId);
62
+ }
63
+ return viewerId;
64
+ } catch {
65
+ return generateId();
66
+ }
67
+ }
68
+ }
69
+ function getBrowserInfo() {
70
+ const ua = navigator.userAgent;
71
+ if (ua.includes("Edg/")) {
72
+ const match = ua.match(/Edg\/(\d+)/);
73
+ return {
74
+ name: "Edge",
75
+ version: match ? match[1] : void 0
76
+ };
77
+ }
78
+ if (ua.includes("Chrome/") && !ua.includes("Edg/")) {
79
+ const match = ua.match(/Chrome\/(\d+)/);
80
+ return {
81
+ name: "Chrome",
82
+ version: match ? match[1] : void 0
83
+ };
84
+ }
85
+ if (ua.includes("Safari/") && !ua.includes("Chrome")) {
86
+ const match = ua.match(/Version\/(\d+)/);
87
+ return {
88
+ name: "Safari",
89
+ version: match ? match[1] : void 0
90
+ };
91
+ }
92
+ if (ua.includes("Firefox/")) {
93
+ const match = ua.match(/Firefox\/(\d+)/);
94
+ return {
95
+ name: "Firefox",
96
+ version: match ? match[1] : void 0
97
+ };
98
+ }
99
+ if (ua.includes("OPR/") || ua.includes("Opera/")) {
100
+ const match = ua.match(/(?:OPR|Opera)\/(\d+)/);
101
+ return {
102
+ name: "Opera",
103
+ version: match ? match[1] : void 0
104
+ };
105
+ }
106
+ return { name: "Unknown" };
107
+ }
108
+ function getOSInfo() {
109
+ const ua = navigator.userAgent;
110
+ const platform = navigator.platform || "";
111
+ if (ua.includes("Windows")) {
112
+ if (ua.includes("Windows NT 10.0")) return { name: "Windows", version: "10" };
113
+ if (ua.includes("Windows NT 6.3")) return { name: "Windows", version: "8.1" };
114
+ if (ua.includes("Windows NT 6.2")) return { name: "Windows", version: "8" };
115
+ if (ua.includes("Windows NT 6.1")) return { name: "Windows", version: "7" };
116
+ return { name: "Windows" };
117
+ }
118
+ if (ua.includes("Mac OS X")) {
119
+ const match = ua.match(/Mac OS X (\d+)[._](\d+)/);
120
+ return {
121
+ name: "macOS",
122
+ version: match ? `${match[1]}.${match[2]}` : void 0
123
+ };
124
+ }
125
+ if (ua.includes("iPhone") || ua.includes("iPad") || ua.includes("iPod")) {
126
+ const match = ua.match(/OS (\d+)[._](\d+)/);
127
+ return {
128
+ name: "iOS",
129
+ version: match ? `${match[1]}.${match[2]}` : void 0
130
+ };
131
+ }
132
+ if (ua.includes("Android")) {
133
+ const match = ua.match(/Android (\d+(?:\.\d+)?)/);
134
+ return {
135
+ name: "Android",
136
+ version: match ? match[1] : void 0
137
+ };
138
+ }
139
+ if (ua.includes("Linux") || platform.includes("Linux")) {
140
+ return { name: "Linux" };
141
+ }
142
+ if (ua.includes("CrOS")) {
143
+ return { name: "ChromeOS" };
144
+ }
145
+ return { name: "Unknown" };
146
+ }
147
+ function getDeviceType() {
148
+ const ua = navigator.userAgent;
149
+ if (ua.includes("TV") || ua.includes("PlayStation") || ua.includes("Xbox") || ua.includes("SmartTV")) {
150
+ return "tv";
151
+ }
152
+ if (ua.includes("iPad") || ua.includes("Android") && !ua.includes("Mobile") || ua.includes("Tablet")) {
153
+ return "tablet";
154
+ }
155
+ if (ua.includes("Mobile") || ua.includes("iPhone") || ua.includes("iPod") || ua.includes("Android") && ua.includes("Mobile")) {
156
+ return "mobile";
157
+ }
158
+ return "desktop";
159
+ }
160
+ function getScreenSize() {
161
+ return `${window.screen.width}x${window.screen.height}`;
162
+ }
163
+ function getPlayerSize(container) {
164
+ if (!container) {
165
+ return `${window.innerWidth}x${window.innerHeight}`;
166
+ }
167
+ const rect = container.getBoundingClientRect();
168
+ return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
169
+ }
170
+ function getConnectionType() {
171
+ const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
172
+ if (conn) {
173
+ return conn.effectiveType || conn.type || "unknown";
174
+ }
175
+ return "unknown";
176
+ }
177
+ function calculateQoEScore(params) {
178
+ const {
179
+ startupTime,
180
+ rebufferDuration,
181
+ watchTime,
182
+ maxBitrate,
183
+ exitType,
184
+ errorCount
185
+ } = params;
186
+ let startupScore = 100;
187
+ if (startupTime !== null) {
188
+ if (startupTime < 1e3) startupScore = 100;
189
+ else if (startupTime < 2e3) startupScore = 85;
190
+ else if (startupTime < 4e3) startupScore = 70;
191
+ else if (startupTime < 8e3) startupScore = 50;
192
+ else startupScore = 30;
193
+ }
194
+ let smoothnessScore = 100;
195
+ if (watchTime > 0) {
196
+ const rebufferRatio = rebufferDuration / watchTime * 100;
197
+ if (rebufferRatio < 0.1) smoothnessScore = 100;
198
+ else if (rebufferRatio < 1) smoothnessScore = 85;
199
+ else if (rebufferRatio < 2) smoothnessScore = 70;
200
+ else if (rebufferRatio < 5) smoothnessScore = 50;
201
+ else smoothnessScore = 30;
202
+ }
203
+ let successScore = 100;
204
+ if (exitType === "error") {
205
+ successScore = 0;
206
+ } else if (errorCount > 0) {
207
+ successScore = Math.max(0, 100 - errorCount * 10);
208
+ }
209
+ let qualityScore = 80;
210
+ if (maxBitrate > 4e6) qualityScore = 100;
211
+ else if (maxBitrate > 2e6) qualityScore = 90;
212
+ else if (maxBitrate > 1e6) qualityScore = 75;
213
+ else if (maxBitrate > 5e5) qualityScore = 60;
214
+ else if (maxBitrate > 0) qualityScore = 40;
215
+ const qoeScore = successScore * 0.3 + startupScore * 0.25 + smoothnessScore * 0.3 + qualityScore * 0.15;
216
+ return Math.round(qoeScore);
217
+ }
218
+ function isDevelopment() {
219
+ return window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" || window.location.hostname.includes(".local");
220
+ }
221
+ function safeStringify(data) {
222
+ try {
223
+ return JSON.stringify(data);
224
+ } catch (error) {
225
+ return "{}";
226
+ }
227
+ }
228
+
229
+ // src/index.ts
230
+ var PLUGIN_VERSION = "0.1.0";
231
+ var PLUGIN_NAME = "scarlett-player";
232
+ var DEFAULT_CONFIG = {
233
+ heartbeatInterval: 1e4,
234
+ errorSampleRate: 1,
235
+ disableInDev: false
236
+ };
237
+ function createAnalyticsPlugin(config) {
238
+ if (!config.beaconUrl) {
239
+ throw new Error("Analytics plugin requires beaconUrl");
240
+ }
241
+ if (!config.videoId) {
242
+ throw new Error("Analytics plugin requires videoId");
243
+ }
244
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config };
245
+ let api = null;
246
+ let session;
247
+ let heartbeatTimer = null;
248
+ let lastHeartbeatTime = 0;
249
+ let isRebuffering = false;
250
+ let rebufferStartTime = null;
251
+ let pauseStartTime = null;
252
+ let cleanupFns = [];
253
+ function initSession() {
254
+ return {
255
+ viewId: generateId(),
256
+ sessionId: getSessionId(),
257
+ viewerId: mergedConfig.viewerId || getAnonymousViewerId(),
258
+ viewStart: Date.now(),
259
+ playRequestTime: null,
260
+ firstFrameTime: null,
261
+ viewEnd: null,
262
+ watchTime: 0,
263
+ playTime: 0,
264
+ pauseCount: 0,
265
+ pauseDuration: 0,
266
+ seekCount: 0,
267
+ startupTime: null,
268
+ rebufferCount: 0,
269
+ rebufferDuration: 0,
270
+ errorCount: 0,
271
+ errors: [],
272
+ bitrateHistory: [],
273
+ qualityChanges: 0,
274
+ maxBitrate: 0,
275
+ avgBitrate: 0,
276
+ playbackState: "loading",
277
+ exitType: null
278
+ };
279
+ }
280
+ function sendBeacon(eventType, data = {}) {
281
+ if (mergedConfig.disableInDev && isDevelopment()) {
282
+ return;
283
+ }
284
+ if (eventType === "error" && Math.random() > (mergedConfig.errorSampleRate ?? 1)) {
285
+ return;
286
+ }
287
+ const payload = {
288
+ // Event info
289
+ event: eventType,
290
+ timestamp: Date.now(),
291
+ // View context
292
+ viewId: session.viewId,
293
+ sessionId: session.sessionId,
294
+ viewerId: session.viewerId,
295
+ // Video context
296
+ videoId: mergedConfig.videoId,
297
+ videoTitle: mergedConfig.videoTitle,
298
+ isLive: mergedConfig.isLive ?? api?.getState("live") ?? false,
299
+ // Player context
300
+ playerVersion: PLUGIN_VERSION,
301
+ playerName: PLUGIN_NAME,
302
+ // Environment
303
+ browser: getBrowserInfo().name,
304
+ os: getOSInfo().name,
305
+ deviceType: getDeviceType(),
306
+ screenSize: getScreenSize(),
307
+ playerSize: getPlayerSize(api?.container ?? null),
308
+ connectionType: getConnectionType(),
309
+ // Custom dimensions
310
+ ...mergedConfig.customDimensions,
311
+ // Event-specific data
312
+ ...data
313
+ };
314
+ if (mergedConfig.customBeacon) {
315
+ mergedConfig.customBeacon(mergedConfig.beaconUrl, payload);
316
+ return;
317
+ }
318
+ if (navigator.sendBeacon) {
319
+ const blob = new Blob([safeStringify(payload)], {
320
+ type: "application/json"
321
+ });
322
+ navigator.sendBeacon(mergedConfig.beaconUrl, blob);
323
+ } else {
324
+ fetch(mergedConfig.beaconUrl, {
325
+ method: "POST",
326
+ headers: {
327
+ "Content-Type": "application/json",
328
+ ...mergedConfig.apiKey ? { "X-API-Key": mergedConfig.apiKey } : {}
329
+ },
330
+ body: safeStringify(payload),
331
+ keepalive: true
332
+ }).catch(() => {
333
+ });
334
+ }
335
+ }
336
+ function sendHeartbeat() {
337
+ if (!api) return;
338
+ const now = Date.now();
339
+ const timeSinceLastHeartbeat = now - lastHeartbeatTime;
340
+ session.watchTime += timeSinceLastHeartbeat;
341
+ if (session.playbackState === "playing" && !isRebuffering) {
342
+ session.playTime += timeSinceLastHeartbeat;
343
+ }
344
+ if (session.bitrateHistory.length > 0) {
345
+ const totalBitrateTime = session.bitrateHistory.reduce((sum, b, i, arr) => {
346
+ const nextTime = i < arr.length - 1 ? arr[i + 1]?.time : now;
347
+ const duration = nextTime - b.time;
348
+ return sum + b.bitrate * duration;
349
+ }, 0);
350
+ session.avgBitrate = Math.round(totalBitrateTime / session.watchTime);
351
+ }
352
+ const state = {
353
+ currentTime: api.getState("currentTime"),
354
+ duration: api.getState("duration")
355
+ };
356
+ sendBeacon("heartbeat", {
357
+ watchTime: session.watchTime,
358
+ playTime: session.playTime,
359
+ currentTime: state.currentTime,
360
+ duration: state.duration,
361
+ rebufferCount: session.rebufferCount,
362
+ rebufferDuration: session.rebufferDuration,
363
+ avgBitrate: session.avgBitrate,
364
+ qoeScore: getQoEScore()
365
+ });
366
+ lastHeartbeatTime = now;
367
+ }
368
+ function getQoEScore() {
369
+ return calculateQoEScore({
370
+ startupTime: session.startupTime,
371
+ rebufferDuration: session.rebufferDuration,
372
+ watchTime: session.watchTime,
373
+ maxBitrate: session.maxBitrate,
374
+ exitType: session.exitType,
375
+ errorCount: session.errorCount
376
+ });
377
+ }
378
+ function sendViewEnd() {
379
+ if (!api) return;
380
+ session.viewEnd = Date.now();
381
+ const state = {
382
+ currentTime: api.getState("currentTime"),
383
+ duration: api.getState("duration")
384
+ };
385
+ const completionRate = state.duration ? state.currentTime / state.duration * 100 : 0;
386
+ sendBeacon("viewEnd", {
387
+ watchTime: session.watchTime,
388
+ playTime: session.playTime,
389
+ startupTime: session.startupTime,
390
+ rebufferCount: session.rebufferCount,
391
+ rebufferDuration: session.rebufferDuration,
392
+ rebufferRatio: session.watchTime > 0 ? session.rebufferDuration / session.watchTime * 100 : 0,
393
+ avgBitrate: session.avgBitrate,
394
+ maxBitrate: session.maxBitrate,
395
+ qualityChanges: session.qualityChanges,
396
+ pauseCount: session.pauseCount,
397
+ pauseDuration: session.pauseDuration,
398
+ seekCount: session.seekCount,
399
+ errorCount: session.errorCount,
400
+ exitType: session.exitType,
401
+ qoeScore: getQoEScore(),
402
+ completionRate
403
+ });
404
+ }
405
+ function onPlayRequest() {
406
+ session.playRequestTime = Date.now();
407
+ sendBeacon("playRequest");
408
+ }
409
+ function onPlaying() {
410
+ const now = Date.now();
411
+ if (session.firstFrameTime === null) {
412
+ session.firstFrameTime = now;
413
+ session.startupTime = session.playRequestTime ? now - session.playRequestTime : null;
414
+ sendBeacon("videoStart", {
415
+ startupTime: session.startupTime
416
+ });
417
+ }
418
+ if (isRebuffering && rebufferStartTime) {
419
+ const rebufferDuration = now - rebufferStartTime;
420
+ session.rebufferDuration += rebufferDuration;
421
+ isRebuffering = false;
422
+ rebufferStartTime = null;
423
+ sendBeacon("rebufferEnd", {
424
+ duration: rebufferDuration,
425
+ totalRebufferTime: session.rebufferDuration
426
+ });
427
+ }
428
+ if (pauseStartTime) {
429
+ const pauseDuration = now - pauseStartTime;
430
+ session.pauseDuration += pauseDuration;
431
+ pauseStartTime = null;
432
+ }
433
+ session.playbackState = "playing";
434
+ }
435
+ function onPause() {
436
+ if (!api) return;
437
+ session.pauseCount++;
438
+ session.playbackState = "paused";
439
+ pauseStartTime = Date.now();
440
+ sendBeacon("pause", {
441
+ currentTime: api.getState("currentTime")
442
+ });
443
+ }
444
+ function onWaiting() {
445
+ if (!api) return;
446
+ if (session.firstFrameTime !== null && !isRebuffering) {
447
+ isRebuffering = true;
448
+ rebufferStartTime = Date.now();
449
+ session.rebufferCount++;
450
+ sendBeacon("rebufferStart", {
451
+ rebufferCount: session.rebufferCount,
452
+ currentTime: api.getState("currentTime")
453
+ });
454
+ }
455
+ }
456
+ function onSeeking() {
457
+ if (!api) return;
458
+ session.seekCount++;
459
+ sendBeacon("seeking", {
460
+ seekCount: session.seekCount,
461
+ seekTo: api.getState("currentTime")
462
+ });
463
+ }
464
+ function onEnded() {
465
+ session.playbackState = "ended";
466
+ session.exitType = "completed";
467
+ sendViewEnd();
468
+ }
469
+ function onError(payload) {
470
+ const error = payload.error;
471
+ session.errorCount++;
472
+ const errorEvent = {
473
+ time: Date.now(),
474
+ type: error.name || "Error",
475
+ message: error.message || "Unknown error",
476
+ fatal: error.fatal ?? false
477
+ };
478
+ session.errors.push(errorEvent);
479
+ sendBeacon("error", {
480
+ errorType: errorEvent.type,
481
+ errorMessage: errorEvent.message,
482
+ errorCode: error.code,
483
+ fatal: errorEvent.fatal
484
+ });
485
+ if (errorEvent.fatal) {
486
+ session.playbackState = "error";
487
+ session.exitType = "error";
488
+ sendViewEnd();
489
+ }
490
+ }
491
+ function onQualityChange(payload) {
492
+ if (!api) return;
493
+ const now = Date.now();
494
+ session.qualityChanges++;
495
+ const qualities = api.getState("qualities");
496
+ const currentQuality = qualities.find((q) => q.id === payload.quality);
497
+ if (currentQuality) {
498
+ const bitrateChange = {
499
+ time: now,
500
+ bitrate: currentQuality.bitrate,
501
+ width: currentQuality.width,
502
+ height: currentQuality.height
503
+ };
504
+ session.bitrateHistory.push(bitrateChange);
505
+ if (currentQuality.bitrate > session.maxBitrate) {
506
+ session.maxBitrate = currentQuality.bitrate;
507
+ }
508
+ sendBeacon("qualityChange", {
509
+ bitrate: currentQuality.bitrate,
510
+ width: currentQuality.width,
511
+ height: currentQuality.height,
512
+ auto: payload.auto
513
+ });
514
+ }
515
+ }
516
+ function onVisibilityChange() {
517
+ if (document.hidden) {
518
+ session.exitType = "background";
519
+ sendHeartbeat();
520
+ }
521
+ }
522
+ function onBeforeUnload() {
523
+ if (!session.exitType) {
524
+ session.exitType = "abandoned";
525
+ }
526
+ sendViewEnd();
527
+ }
528
+ return {
529
+ id: "analytics",
530
+ name: "Analytics",
531
+ version: PLUGIN_VERSION,
532
+ type: "analytics",
533
+ description: "Quality of Experience and engagement analytics",
534
+ async init(pluginApi) {
535
+ api = pluginApi;
536
+ session = initSession();
537
+ lastHeartbeatTime = Date.now();
538
+ sendBeacon("viewStart");
539
+ const unsubPlay = api.on("playback:play", () => {
540
+ onPlayRequest();
541
+ onPlaying();
542
+ });
543
+ const unsubPause = api.on("playback:pause", onPause);
544
+ const unsubWaiting = api.on("media:waiting", onWaiting);
545
+ const unsubSeeking = api.on("playback:seeking", onSeeking);
546
+ const unsubEnded = api.on("playback:ended", onEnded);
547
+ const unsubError = api.on("media:error", onError);
548
+ const unsubQuality = api.on("quality:change", onQualityChange);
549
+ cleanupFns.push(
550
+ unsubPlay,
551
+ unsubPause,
552
+ unsubWaiting,
553
+ unsubSeeking,
554
+ unsubEnded,
555
+ unsubError,
556
+ unsubQuality
557
+ );
558
+ document.addEventListener("visibilitychange", onVisibilityChange);
559
+ window.addEventListener("beforeunload", onBeforeUnload);
560
+ cleanupFns.push(() => {
561
+ document.removeEventListener("visibilitychange", onVisibilityChange);
562
+ window.removeEventListener("beforeunload", onBeforeUnload);
563
+ });
564
+ heartbeatTimer = setInterval(
565
+ sendHeartbeat,
566
+ mergedConfig.heartbeatInterval || 1e4
567
+ );
568
+ api.logger.info("Analytics plugin initialized", {
569
+ viewId: session.viewId,
570
+ videoId: mergedConfig.videoId
571
+ });
572
+ },
573
+ async destroy() {
574
+ if (heartbeatTimer) {
575
+ clearInterval(heartbeatTimer);
576
+ heartbeatTimer = null;
577
+ }
578
+ if (!session.viewEnd) {
579
+ session.exitType = session.exitType || "abandoned";
580
+ sendViewEnd();
581
+ }
582
+ cleanupFns.forEach((fn) => fn());
583
+ cleanupFns = [];
584
+ api?.logger.info("Analytics plugin destroyed");
585
+ api = null;
586
+ },
587
+ // === Public API ===
588
+ getViewId() {
589
+ return session.viewId;
590
+ },
591
+ getSessionId() {
592
+ return session.sessionId;
593
+ },
594
+ getQoEScore() {
595
+ return getQoEScore();
596
+ },
597
+ getMetrics() {
598
+ return { ...session };
599
+ },
600
+ trackEvent(name, data = {}) {
601
+ sendBeacon(`custom:${name}`, data);
602
+ }
603
+ };
604
+ }
605
+ var index_default = createAnalyticsPlugin;
606
+ // Annotate the CommonJS export names for ESM import in node:
607
+ 0 && (module.exports = {
608
+ createAnalyticsPlugin
609
+ });