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