@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/core/EventTracker.d.ts +38 -0
- package/dist/core/EventTracker.d.ts.map +1 -0
- package/dist/core/ViewerFingerprint.d.ts +13 -0
- package/dist/core/ViewerFingerprint.d.ts.map +1 -0
- package/dist/core/WatchChunkAccumulator.d.ts +20 -0
- package/dist/core/WatchChunkAccumulator.d.ts.map +1 -0
- package/dist/core/player.d.ts +4 -0
- package/dist/core/player.d.ts.map +1 -1
- package/dist/core/types.d.ts +4 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +546 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +548 -5
- package/dist/index.js.map +1 -1
- package/dist/ui/PlayerUI.d.ts +6 -0
- package/dist/ui/PlayerUI.d.ts.map +1 -1
- package/dist/vanilla/guardvideo-player.js +545 -5
- package/dist/vanilla/guardvideo-player.js.map +1 -1
- package/dist/vanilla/guardvideo-player.min.js +1 -1
- package/dist/vanilla/guardvideo-player.min.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
1749
|
-
|
|
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
|