@guardvideo/player-sdk 2.1.1 → 3.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.
package/dist/index.js CHANGED
@@ -14,6 +14,365 @@ exports.PlayerState = void 0;
14
14
  PlayerState["ERROR"] = "error";
15
15
  })(exports.PlayerState || (exports.PlayerState = {}));
16
16
 
17
+ const MAX_CHUNK_SECONDS = 30;
18
+ class WatchChunkAccumulator {
19
+ constructor() {
20
+ this.chunkStart = null;
21
+ this.lastPosition = 0;
22
+ this.pendingChunks = [];
23
+ }
24
+ startChunk(positionSeconds) {
25
+ if (this.chunkStart === null) {
26
+ this.chunkStart = positionSeconds;
27
+ this.lastPosition = positionSeconds;
28
+ }
29
+ }
30
+ tick(positionSeconds) {
31
+ if (this.chunkStart === null)
32
+ return;
33
+ this.lastPosition = positionSeconds;
34
+ const elapsed = positionSeconds - this.chunkStart;
35
+ if (elapsed >= MAX_CHUNK_SECONDS) {
36
+ this.finalizeChunk();
37
+ this.chunkStart = positionSeconds;
38
+ }
39
+ }
40
+ finalizeChunk() {
41
+ if (this.chunkStart === null)
42
+ return null;
43
+ const duration = this.lastPosition - this.chunkStart;
44
+ if (duration < 0.5) {
45
+ this.chunkStart = null;
46
+ return null;
47
+ }
48
+ const chunk = {
49
+ event_type: 'watch_chunk',
50
+ event_at: new Date().toISOString(),
51
+ position_seconds: this.lastPosition,
52
+ payload: {
53
+ start_seconds: this.chunkStart,
54
+ duration_seconds: Math.round(duration * 100) / 100,
55
+ },
56
+ };
57
+ this.pendingChunks.push(chunk);
58
+ this.chunkStart = null;
59
+ return chunk;
60
+ }
61
+ drain() {
62
+ const chunks = [...this.pendingChunks];
63
+ this.pendingChunks = [];
64
+ return chunks;
65
+ }
66
+ reset() {
67
+ this.chunkStart = null;
68
+ this.lastPosition = 0;
69
+ this.pendingChunks = [];
70
+ }
71
+ }
72
+
73
+ const MAX_BATCH_SIZE = 25;
74
+ const DEFAULT_FLUSH_INTERVAL = 5000;
75
+ class EventTracker {
76
+ constructor(config) {
77
+ this.buffer = [];
78
+ this.flushTimer = null;
79
+ this.accumulator = new WatchChunkAccumulator();
80
+ this.destroyed = false;
81
+ this._onBeforeUnload = null;
82
+ this.config = {
83
+ endpoint: config.endpoint,
84
+ tokenId: config.tokenId,
85
+ sessionId: config.sessionId || '',
86
+ eventsSecret: config.eventsSecret,
87
+ identityHash: config.identityHash || '',
88
+ flushIntervalMs: config.flushIntervalMs || DEFAULT_FLUSH_INTERVAL,
89
+ debug: config.debug || false,
90
+ };
91
+ this.startAutoFlush();
92
+ this.hookPageUnload();
93
+ }
94
+ track(type, positionSeconds, payload) {
95
+ if (this.destroyed)
96
+ return;
97
+ this.buffer.push({
98
+ event_type: type,
99
+ event_at: new Date().toISOString(),
100
+ position_seconds: positionSeconds,
101
+ payload,
102
+ });
103
+ if (this.buffer.length >= MAX_BATCH_SIZE) {
104
+ this.flush();
105
+ }
106
+ }
107
+ startChunk(position) {
108
+ this.accumulator.startChunk(position);
109
+ }
110
+ tickChunk(position) {
111
+ this.accumulator.tick(position);
112
+ }
113
+ endChunk() {
114
+ this.accumulator.finalizeChunk();
115
+ }
116
+ setSessionId(sessionId) {
117
+ this.config.sessionId = sessionId;
118
+ }
119
+ async flush() {
120
+ if (this.destroyed)
121
+ return;
122
+ const chunks = this.accumulator.drain();
123
+ for (const c of chunks) {
124
+ this.buffer.push(c);
125
+ }
126
+ if (this.buffer.length === 0)
127
+ return;
128
+ const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
129
+ await this.sendBatch(batch);
130
+ }
131
+ destroy() {
132
+ this.destroyed = true;
133
+ this.accumulator.finalizeChunk();
134
+ this.flush();
135
+ if (this.flushTimer)
136
+ clearInterval(this.flushTimer);
137
+ if (this._onBeforeUnload) {
138
+ window.removeEventListener('beforeunload', this._onBeforeUnload);
139
+ }
140
+ }
141
+ startAutoFlush() {
142
+ this.flushTimer = setInterval(() => this.flush(), this.config.flushIntervalMs);
143
+ }
144
+ hookPageUnload() {
145
+ if (typeof window === 'undefined')
146
+ return;
147
+ this._onBeforeUnload = () => {
148
+ this.accumulator.finalizeChunk();
149
+ const chunks = this.accumulator.drain();
150
+ const allEvents = [...this.buffer, ...chunks];
151
+ if (allEvents.length === 0)
152
+ return;
153
+ const body = JSON.stringify({
154
+ tokenId: this.config.tokenId,
155
+ sessionId: this.config.sessionId || undefined,
156
+ identityHash: this.config.identityHash || undefined,
157
+ events: allEvents.slice(0, MAX_BATCH_SIZE),
158
+ });
159
+ try {
160
+ navigator.sendBeacon(this.config.endpoint, new Blob([body], { type: 'application/json' }));
161
+ }
162
+ catch {
163
+ }
164
+ };
165
+ window.addEventListener('beforeunload', this._onBeforeUnload);
166
+ }
167
+ async sendBatch(events) {
168
+ const body = JSON.stringify({
169
+ tokenId: this.config.tokenId,
170
+ sessionId: this.config.sessionId || undefined,
171
+ identityHash: this.config.identityHash || undefined,
172
+ events,
173
+ });
174
+ const nonce = this.randomNonce();
175
+ const timestamp = String(Date.now());
176
+ const signature = await this.sign(nonce, timestamp, body);
177
+ try {
178
+ const resp = await fetch(this.config.endpoint, {
179
+ method: 'POST',
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ 'X-GV-Nonce': nonce,
183
+ 'X-GV-Timestamp': timestamp,
184
+ 'X-GV-Signature': signature,
185
+ },
186
+ body,
187
+ credentials: 'omit',
188
+ });
189
+ if (!resp.ok && this.config.debug) {
190
+ console.warn('[GuardVideo EventTracker] Flush failed:', resp.status);
191
+ }
192
+ }
193
+ catch (err) {
194
+ if (this.config.debug) {
195
+ console.warn('[GuardVideo EventTracker] Flush error:', err);
196
+ }
197
+ this.buffer.unshift(...events);
198
+ }
199
+ }
200
+ randomNonce() {
201
+ const arr = new Uint8Array(8);
202
+ crypto.getRandomValues(arr);
203
+ return Array.from(arr)
204
+ .map((b) => b.toString(16).padStart(2, '0'))
205
+ .join('');
206
+ }
207
+ async sign(nonce, timestamp, body) {
208
+ const enc = new TextEncoder();
209
+ const key = await crypto.subtle.importKey('raw', enc.encode(this.config.eventsSecret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
210
+ const message = `${nonce}.${timestamp}.${body}`;
211
+ const sig = await crypto.subtle.sign('HMAC', key, enc.encode(message));
212
+ return Array.from(new Uint8Array(sig))
213
+ .map((b) => b.toString(16).padStart(2, '0'))
214
+ .join('');
215
+ }
216
+ }
217
+
218
+ async function shortHash(input) {
219
+ const buf = new TextEncoder().encode(input);
220
+ const digest = await crypto.subtle.digest('SHA-256', buf);
221
+ return Array.from(new Uint8Array(digest))
222
+ .map((b) => b.toString(16).padStart(2, '0'))
223
+ .join('')
224
+ .substring(0, 16);
225
+ }
226
+ async function longHash(input) {
227
+ const buf = new TextEncoder().encode(input);
228
+ const digest = await crypto.subtle.digest('SHA-256', buf);
229
+ return Array.from(new Uint8Array(digest))
230
+ .map((b) => b.toString(16).padStart(2, '0'))
231
+ .join('')
232
+ .substring(0, 32);
233
+ }
234
+ function collectCanvas() {
235
+ try {
236
+ const c = document.createElement('canvas');
237
+ c.width = 200;
238
+ c.height = 50;
239
+ const ctx = c.getContext('2d');
240
+ if (!ctx)
241
+ return 'no-canvas';
242
+ ctx.textBaseline = 'top';
243
+ ctx.font = '14px Arial';
244
+ ctx.fillStyle = '#f60';
245
+ ctx.fillRect(125, 1, 62, 20);
246
+ ctx.fillStyle = '#069';
247
+ ctx.fillText('GuardVideo<canvas>', 2, 15);
248
+ ctx.fillStyle = 'rgba(102,204,0,0.7)';
249
+ ctx.fillText('fingerprint', 4, 35);
250
+ return c.toDataURL();
251
+ }
252
+ catch {
253
+ return 'canvas-error';
254
+ }
255
+ }
256
+ async function collectAudio() {
257
+ try {
258
+ const ctx = new (window.OfflineAudioContext ||
259
+ window.webkitOfflineAudioContext)(1, 44100, 44100);
260
+ const osc = ctx.createOscillator();
261
+ osc.type = 'triangle';
262
+ osc.frequency.setValueAtTime(10000, ctx.currentTime);
263
+ const comp = ctx.createDynamicsCompressor();
264
+ comp.threshold.setValueAtTime(-50, ctx.currentTime);
265
+ comp.knee.setValueAtTime(40, ctx.currentTime);
266
+ comp.ratio.setValueAtTime(12, ctx.currentTime);
267
+ comp.attack.setValueAtTime(0, ctx.currentTime);
268
+ comp.release.setValueAtTime(0.25, ctx.currentTime);
269
+ osc.connect(comp);
270
+ comp.connect(ctx.destination);
271
+ osc.start(0);
272
+ const rendered = await ctx.startRendering();
273
+ const data = rendered.getChannelData(0);
274
+ let sum = 0;
275
+ for (let i = 4500; i < 5000; i++)
276
+ sum += Math.abs(data[i]);
277
+ return sum.toString();
278
+ }
279
+ catch {
280
+ return 'audio-error';
281
+ }
282
+ }
283
+ function collectNav() {
284
+ const n = navigator;
285
+ return [
286
+ n.hardwareConcurrency || 0,
287
+ n.deviceMemory || 0,
288
+ n.maxTouchPoints || 0,
289
+ n.platform || '',
290
+ n.userAgentData?.platform || '',
291
+ n.userAgentData?.mobile ? '1' : '0',
292
+ ].join('|');
293
+ }
294
+ function collectFonts() {
295
+ try {
296
+ const baseFonts = ['monospace', 'sans-serif', 'serif'];
297
+ const testFonts = [
298
+ 'Arial', 'Courier New', 'Georgia', 'Times New Roman',
299
+ 'Verdana', 'Trebuchet MS', 'Palatino', 'Impact', 'Comic Sans MS',
300
+ ];
301
+ const span = document.createElement('span');
302
+ span.style.cssText = 'position:absolute;left:-9999px;font-size:72px;visibility:hidden';
303
+ span.textContent = 'mmmmmmmmmmlli';
304
+ document.body.appendChild(span);
305
+ const baseSizes = {};
306
+ for (const bf of baseFonts) {
307
+ span.style.fontFamily = bf;
308
+ baseSizes[bf] = span.offsetWidth;
309
+ }
310
+ const detected = [];
311
+ for (const tf of testFonts) {
312
+ for (const bf of baseFonts) {
313
+ span.style.fontFamily = `'${tf}', ${bf}`;
314
+ if (span.offsetWidth !== baseSizes[bf]) {
315
+ detected.push(tf);
316
+ break;
317
+ }
318
+ }
319
+ }
320
+ document.body.removeChild(span);
321
+ return detected.join(',');
322
+ }
323
+ catch {
324
+ return 'font-error';
325
+ }
326
+ }
327
+ function collectMedia() {
328
+ const v = document.createElement('video');
329
+ const types = [
330
+ 'video/mp4; codecs="avc1.42E01E"',
331
+ 'video/webm; codecs="vp8"',
332
+ 'video/webm; codecs="vp9"',
333
+ 'video/ogg; codecs="theora"',
334
+ 'audio/mp4; codecs="mp4a.40.2"',
335
+ 'audio/webm; codecs="opus"',
336
+ ];
337
+ return types.map((t) => v.canPlayType(t) || '').join('|');
338
+ }
339
+ function collectScreen() {
340
+ const s = window.screen;
341
+ return [s.width, s.height, s.colorDepth, window.devicePixelRatio || 1].join('x');
342
+ }
343
+ async function collectFingerprint() {
344
+ const [canvasRaw, audioRaw] = await Promise.all([
345
+ Promise.resolve(collectCanvas()),
346
+ collectAudio(),
347
+ ]);
348
+ const navRaw = collectNav();
349
+ const fontRaw = collectFonts();
350
+ const mediaRaw = collectMedia();
351
+ const screenRaw = collectScreen();
352
+ const tzCode = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown';
353
+ const langTag = navigator.language || 'unknown';
354
+ const [canvas_hash, audio_hash, nav_hash, font_hash, media_hash, screen_hash] = await Promise.all([
355
+ shortHash(canvasRaw),
356
+ shortHash(audioRaw),
357
+ shortHash(navRaw).then((h) => h.substring(0, 8)),
358
+ shortHash(fontRaw),
359
+ shortHash(mediaRaw),
360
+ shortHash(screenRaw),
361
+ ]);
362
+ const combined = await longHash([canvas_hash, audio_hash, nav_hash, font_hash, media_hash, screen_hash, tzCode, langTag].join('.'));
363
+ return {
364
+ canvas_hash,
365
+ audio_hash,
366
+ nav_hash,
367
+ font_hash,
368
+ media_hash,
369
+ screen_hash,
370
+ tz_code: tzCode,
371
+ lang_tag: langTag,
372
+ combined,
373
+ };
374
+ }
375
+
17
376
  const DEFAULT_BRANDING = {
18
377
  name: 'GuardVideo',
19
378
  url: 'https://guardvid.com',
@@ -39,6 +398,9 @@ class GuardVideoPlayer {
39
398
  this.state = exports.PlayerState.IDLE;
40
399
  this.embedToken = null;
41
400
  this.currentQuality = null;
401
+ this.eventTracker = null;
402
+ this.sdkFingerprint = null;
403
+ this._onSecurityEvent = null;
42
404
  this._onRateChange = this.enforceMaxRate.bind(this);
43
405
  this.videoElement = videoElement;
44
406
  this.config = {
@@ -56,6 +418,9 @@ class GuardVideoPlayer {
56
418
  viewerName: config.viewerName || '',
57
419
  viewerEmail: config.viewerEmail || '',
58
420
  forensicWatermark: config.forensicWatermark !== false,
421
+ trackViewerEvents: config.trackViewerEvents !== false,
422
+ viewerEventsFlushIntervalMs: config.viewerEventsFlushIntervalMs || 5000,
423
+ collectViewerFingerprint: config.collectViewerFingerprint !== false,
59
424
  onReady: config.onReady || (() => { }),
60
425
  onError: config.onError || (() => { }),
61
426
  onQualityChange: config.onQualityChange || (() => { }),
@@ -66,6 +431,14 @@ class GuardVideoPlayer {
66
431
  if (!this.checkAllowedDomain())
67
432
  return;
68
433
  this.applySecurity();
434
+ if (this.config.collectViewerFingerprint) {
435
+ collectFingerprint().then(fp => {
436
+ this.sdkFingerprint = fp;
437
+ this.log('Fingerprint collected', fp.combined);
438
+ }).catch(() => {
439
+ this.log('Fingerprint collection failed (non-fatal)');
440
+ });
441
+ }
69
442
  this.initialize();
70
443
  }
71
444
  log(message, data) {
@@ -160,6 +533,9 @@ class GuardVideoPlayer {
160
533
  if (cfg.enableWatermark && cfg.watermarkText) {
161
534
  this.config.onWatermark?.(cfg.watermarkText);
162
535
  }
536
+ if (this.config.trackViewerEvents && cfg.eventsSecret) {
537
+ this.initEventTracker(cfg.eventsSecret, cfg.sessionId ?? undefined);
538
+ }
163
539
  }
164
540
  catch (err) {
165
541
  this.log('fetchAndApplyWatermark error (non-fatal):', err);
@@ -169,6 +545,42 @@ class GuardVideoPlayer {
169
545
  }
170
546
  }
171
547
  }
548
+ initEventTracker(eventsSecret, sessionId) {
549
+ if (!this.embedToken || !this.config.apiBaseUrl)
550
+ return;
551
+ this.eventTracker = new EventTracker({
552
+ endpoint: this.config.apiBaseUrl + '/videos/viewer-events',
553
+ tokenId: this.embedToken.tokenId,
554
+ sessionId,
555
+ eventsSecret,
556
+ identityHash: this.sdkFingerprint?.combined,
557
+ flushIntervalMs: this.config.viewerEventsFlushIntervalMs,
558
+ debug: this.config.debug,
559
+ });
560
+ this.videoElement.addEventListener('play', () => {
561
+ this.eventTracker?.track('play', this.videoElement.currentTime);
562
+ this.eventTracker?.startChunk(this.videoElement.currentTime);
563
+ });
564
+ this.videoElement.addEventListener('pause', () => {
565
+ this.eventTracker?.track('pause', this.videoElement.currentTime);
566
+ this.eventTracker?.endChunk();
567
+ });
568
+ this.videoElement.addEventListener('seeked', () => {
569
+ this.eventTracker?.track('seek', this.videoElement.currentTime);
570
+ });
571
+ this.videoElement.addEventListener('ended', () => {
572
+ this.eventTracker?.track('ended', this.videoElement.currentTime);
573
+ this.eventTracker?.endChunk();
574
+ });
575
+ this.videoElement.addEventListener('timeupdate', () => {
576
+ this.eventTracker?.tickChunk(this.videoElement.currentTime);
577
+ });
578
+ this._onSecurityEvent = ((e) => {
579
+ this.eventTracker?.track('security_event', this.videoElement.currentTime, e.detail);
580
+ });
581
+ document.addEventListener('gv:security', this._onSecurityEvent);
582
+ this.log('EventTracker initialized');
583
+ }
172
584
  async fetchEmbedToken() {
173
585
  const url = this.config.embedTokenEndpoint + '/' + this.videoId;
174
586
  this.log('Fetching embed token from', url);
@@ -182,6 +594,7 @@ class GuardVideoPlayer {
182
594
  ...(this.config.viewerName ? { viewerName: this.config.viewerName } : {}),
183
595
  ...(this.config.viewerEmail ? { viewerEmail: this.config.viewerEmail } : {}),
184
596
  forensicWatermark: this.config.forensicWatermark,
597
+ ...(this.sdkFingerprint ? { sdkFingerprint: this.sdkFingerprint } : {}),
185
598
  }),
186
599
  });
187
600
  if (!response.ok) {
@@ -329,6 +742,14 @@ class GuardVideoPlayer {
329
742
  getState() { return this.state; }
330
743
  destroy() {
331
744
  this.log('Destroying player');
745
+ if (this.eventTracker) {
746
+ this.eventTracker.destroy();
747
+ this.eventTracker = null;
748
+ }
749
+ if (this._onSecurityEvent) {
750
+ document.removeEventListener('gv:security', this._onSecurityEvent);
751
+ this._onSecurityEvent = null;
752
+ }
332
753
  this.videoElement.removeEventListener('ratechange', this._onRateChange);
333
754
  if (this.hls) {
334
755
  this.hls.destroy();
@@ -980,6 +1401,12 @@ function injectStyles() {
980
1401
  pointer-events: none;
981
1402
  letter-spacing: 0.06em;
982
1403
  }
1404
+ /* ── Canvas watermark layer (Layer 2) ─────────────────────── */
1405
+ .gvp-watermark-canvas {
1406
+ position: absolute; inset: 0;
1407
+ pointer-events: none; z-index: 7;
1408
+ width: 100%; height: 100%;
1409
+ }
983
1410
 
984
1411
  /* ── Live dot (for live streams) ─────────────────────────── */
985
1412
  .gvp-live-badge {
@@ -1145,6 +1572,7 @@ class PlayerUI {
1145
1572
  this._ctxKeyDownBound = () => { };
1146
1573
  this._watermarkObserver = null;
1147
1574
  this._watermarkText = '';
1575
+ this._watermarkDriftTimer = null;
1148
1576
  const accent = config.branding?.accentColor ?? '#00e5a0';
1149
1577
  const brandName = config.branding?.name ?? 'GuardVideo';
1150
1578
  injectStyles();
@@ -1165,6 +1593,9 @@ class PlayerUI {
1165
1593
  this.badge.appendChild(document.createTextNode(brandName));
1166
1594
  this.watermarkDiv = el('div', 'gvp-watermark');
1167
1595
  this.watermarkDiv.setAttribute('aria-hidden', 'true');
1596
+ this.watermarkCanvas = document.createElement('canvas');
1597
+ this.watermarkCanvas.className = 'gvp-watermark-canvas';
1598
+ this.watermarkCanvas.setAttribute('aria-hidden', 'true');
1168
1599
  this.spinner = el('div', 'gvp-spinner gvp-hidden');
1169
1600
  this.spinner.setAttribute('aria-label', 'Loading');
1170
1601
  this.spinner.setAttribute('role', 'status');
@@ -1278,7 +1709,7 @@ class PlayerUI {
1278
1709
  btnRow.append(this.playBtn, volWrap, this.timeEl, spacer, speedWrap, divider, qualWrap, this.fsBtn);
1279
1710
  inner.appendChild(btnRow);
1280
1711
  this.controls.appendChild(inner);
1281
- this.root.append(this.videoEl, this.badge, this.watermarkDiv, this.spinner, this.errorOverlay, this.centerPlay, this.clickArea, this.controls);
1712
+ this.root.append(this.videoEl, this.badge, this.watermarkDiv, this.watermarkCanvas, this.spinner, this.errorOverlay, this.centerPlay, this.clickArea, this.controls);
1282
1713
  container.appendChild(this.root);
1283
1714
  this._onFsChangeBound = () => this._onFsChange();
1284
1715
  this._seekMouseMoveBound = (e) => { if (this.seekDragging)
@@ -1710,45 +2141,152 @@ class PlayerUI {
1710
2141
  return;
1711
2142
  this._watermarkText = text;
1712
2143
  this.watermarkDiv.innerHTML = '';
2144
+ const seed = this._hashCode(text);
2145
+ const prng = this._mulberry32(seed);
1713
2146
  for (let i = 0; i < 20; i++) {
1714
2147
  const span = el('span', 'gvp-watermark-text');
1715
2148
  span.textContent = text;
1716
- span.style.left = `${(i % 4) * 26 + (Math.floor(i / 4) % 2) * 13}%`;
1717
- span.style.top = `${Math.floor(i / 4) * 22}%`;
2149
+ const baseLeft = (i % 4) * 26 + (Math.floor(i / 4) % 2) * 13;
2150
+ const baseTop = Math.floor(i / 4) * 22;
2151
+ const jitterX = (prng() - 0.5) * 16;
2152
+ const jitterY = (prng() - 0.5) * 10;
2153
+ span.style.left = `${Math.max(0, Math.min(90, baseLeft + jitterX))}%`;
2154
+ span.style.top = `${Math.max(0, Math.min(90, baseTop + jitterY))}%`;
2155
+ const rotJitter = -28 + (prng() - 0.5) * 8;
2156
+ span.style.transform = `rotate(${rotJitter}deg)`;
2157
+ span.style.webkitTransform = `rotate(${rotJitter}deg)`;
1718
2158
  this.watermarkDiv.appendChild(span);
1719
2159
  }
2160
+ this._renderCanvasWatermark(text);
1720
2161
  this._mountWatermarkObserver();
2162
+ this._startWatermarkDrift();
2163
+ }
2164
+ _renderCanvasWatermark(text) {
2165
+ const canvas = this.watermarkCanvas;
2166
+ const w = this.root.clientWidth || 640;
2167
+ const h = this.root.clientHeight || 360;
2168
+ canvas.width = w;
2169
+ canvas.height = h;
2170
+ const ctx = canvas.getContext('2d');
2171
+ if (!ctx)
2172
+ return;
2173
+ ctx.clearRect(0, 0, w, h);
2174
+ ctx.font = '12px monospace';
2175
+ ctx.fillStyle = 'rgba(255,255,255,0.04)';
2176
+ ctx.textBaseline = 'top';
2177
+ const seed = this._hashCode(text);
2178
+ const prng = this._mulberry32(seed);
2179
+ for (let i = 0; i < 20; i++) {
2180
+ const baseLeft = (i % 4) * 26 + (Math.floor(i / 4) % 2) * 13;
2181
+ const baseTop = Math.floor(i / 4) * 22;
2182
+ const jitterX = (prng() - 0.5) * 16;
2183
+ const jitterY = (prng() - 0.5) * 10;
2184
+ const x = Math.max(0, Math.min(90, baseLeft + jitterX)) / 100 * w;
2185
+ const y = Math.max(0, Math.min(90, baseTop + jitterY)) / 100 * h;
2186
+ const rot = (-28 + (prng() - 0.5) * 8) * Math.PI / 180;
2187
+ ctx.save();
2188
+ ctx.translate(x, y);
2189
+ ctx.rotate(rot);
2190
+ ctx.fillText(text, 0, 0);
2191
+ ctx.restore();
2192
+ }
2193
+ }
2194
+ _mulberry32(seed) {
2195
+ let s = seed | 0;
2196
+ return () => {
2197
+ s = (s + 0x6d2b79f5) | 0;
2198
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
2199
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
2200
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
2201
+ };
2202
+ }
2203
+ _hashCode(s) {
2204
+ let h = 5381;
2205
+ for (let i = 0; i < s.length; i++) {
2206
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
2207
+ }
2208
+ return h;
2209
+ }
2210
+ _startWatermarkDrift() {
2211
+ if (this._watermarkDriftTimer)
2212
+ clearInterval(this._watermarkDriftTimer);
2213
+ const seed = this._hashCode(this._watermarkText);
2214
+ const prng = this._mulberry32(seed + Date.now());
2215
+ this._watermarkDriftTimer = setInterval(() => {
2216
+ const spans = this.watermarkDiv.querySelectorAll('.gvp-watermark-text');
2217
+ spans.forEach((span, i) => {
2218
+ const baseLeft = (i % 4) * 26 + (Math.floor(i / 4) % 2) * 13;
2219
+ const baseTop = Math.floor(i / 4) * 22;
2220
+ const jX = (prng() - 0.5) * 16;
2221
+ const jY = (prng() - 0.5) * 10;
2222
+ span.style.transition = 'left 5s ease, top 5s ease';
2223
+ span.style.left = `${Math.max(0, Math.min(90, baseLeft + jX))}%`;
2224
+ span.style.top = `${Math.max(0, Math.min(90, baseTop + jY))}%`;
2225
+ });
2226
+ this._renderCanvasWatermark(this._watermarkText);
2227
+ }, 30000);
1721
2228
  }
1722
2229
  _mountWatermarkObserver() {
1723
2230
  this._watermarkObserver?.disconnect();
1724
2231
  this._watermarkObserver = new MutationObserver((mutations) => {
2232
+ let tampered = false;
1725
2233
  for (const m of mutations) {
1726
2234
  if (m.type === 'childList' && m.target === this.root) {
1727
2235
  if (!this.root.contains(this.watermarkDiv)) {
1728
2236
  this.root.appendChild(this.watermarkDiv);
2237
+ tampered = true;
2238
+ }
2239
+ if (!this.root.contains(this.watermarkCanvas)) {
2240
+ this.root.appendChild(this.watermarkCanvas);
2241
+ tampered = true;
1729
2242
  }
1730
2243
  }
1731
2244
  if (m.type === 'attributes' && m.target === this.watermarkDiv) {
1732
2245
  this.watermarkDiv.removeAttribute('style');
1733
2246
  this.watermarkDiv.className = 'gvp-watermark';
1734
2247
  this.watermarkDiv.setAttribute('aria-hidden', 'true');
2248
+ tampered = true;
2249
+ }
2250
+ if (m.type === 'attributes' && m.target === this.watermarkCanvas) {
2251
+ this.watermarkCanvas.className = 'gvp-watermark-canvas';
2252
+ this.watermarkCanvas.setAttribute('aria-hidden', 'true');
2253
+ this._renderCanvasWatermark(this._watermarkText);
2254
+ tampered = true;
1735
2255
  }
1736
2256
  if (m.type === 'childList' && m.target === this.watermarkDiv) {
1737
2257
  if (this.watermarkDiv.childElementCount < 20) {
1738
2258
  this._refillWatermark();
2259
+ tampered = true;
1739
2260
  }
1740
2261
  }
1741
2262
  }
2263
+ if (tampered) {
2264
+ this.root.dispatchEvent(new CustomEvent('gv:security', {
2265
+ bubbles: true,
2266
+ detail: { type: 'watermark_tamper', timestamp: Date.now() },
2267
+ }));
2268
+ }
1742
2269
  });
1743
2270
  this._watermarkObserver.observe(this.root, { childList: true });
1744
2271
  this._watermarkObserver.observe(this.watermarkDiv, { attributes: true, childList: true });
1745
2272
  }
1746
2273
  _refillWatermark() {
2274
+ const seed = this._hashCode(this._watermarkText);
2275
+ const prng = this._mulberry32(seed);
2276
+ for (let j = 0; j < this.watermarkDiv.childElementCount * 3; j++)
2277
+ prng();
1747
2278
  for (let i = this.watermarkDiv.childElementCount; i < 20; i++) {
1748
2279
  const span = el('span', 'gvp-watermark-text');
1749
2280
  span.textContent = this._watermarkText;
1750
- span.style.left = `${(i % 4) * 26 + (Math.floor(i / 4) % 2) * 13}%`;
1751
- span.style.top = `${Math.floor(i / 4) * 22}%`;
2281
+ const baseLeft = (i % 4) * 26 + (Math.floor(i / 4) % 2) * 13;
2282
+ const baseTop = Math.floor(i / 4) * 22;
2283
+ const jX = (prng() - 0.5) * 16;
2284
+ const jY = (prng() - 0.5) * 10;
2285
+ span.style.left = `${Math.max(0, Math.min(90, baseLeft + jX))}%`;
2286
+ span.style.top = `${Math.max(0, Math.min(90, baseTop + jY))}%`;
2287
+ const rotJitter = -28 + (prng() - 0.5) * 8;
2288
+ span.style.transform = `rotate(${rotJitter}deg)`;
2289
+ span.style.webkitTransform = `rotate(${rotJitter}deg)`;
1752
2290
  this.watermarkDiv.appendChild(span);
1753
2291
  }
1754
2292
  }
@@ -1939,6 +2477,8 @@ class PlayerUI {
1939
2477
  document.removeEventListener('keydown', this._ctxKeyDownBound);
1940
2478
  this._hideContextMenu();
1941
2479
  this._watermarkObserver?.disconnect();
2480
+ if (this._watermarkDriftTimer)
2481
+ clearInterval(this._watermarkDriftTimer);
1942
2482
  this.corePlayer.destroy();
1943
2483
  this.root.remove();
1944
2484
  }
@@ -2079,7 +2619,10 @@ function useGuardVideoPlayer(options) {
2079
2619
  };
2080
2620
  }
2081
2621
 
2622
+ exports.EventTracker = EventTracker;
2082
2623
  exports.GuardVideoPlayer = GuardVideoPlayerComponent;
2083
2624
  exports.GuardVideoPlayerCore = GuardVideoPlayer;
2625
+ exports.WatchChunkAccumulator = WatchChunkAccumulator;
2626
+ exports.collectFingerprint = collectFingerprint;
2084
2627
  exports.useGuardVideoPlayer = useGuardVideoPlayer;
2085
2628
  //# sourceMappingURL=index.js.map