@guardvideo/player-sdk 2.0.0 → 2.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
@@ -33,23 +33,12 @@ const DEFAULT_SECURITY = {
33
33
  class GuardVideoPlayer {
34
34
  constructor(videoElement, videoId, config) {
35
35
  this.videoId = videoId;
36
- this.container = null;
37
36
  this.hls = null;
38
37
  this.state = PlayerState.IDLE;
39
38
  this.embedToken = null;
40
39
  this.currentQuality = null;
41
- this.ctxMenu = null;
42
- this.ctxStyleTag = null;
43
- this.watermarkEl = null;
44
- this.watermarkObserver = null;
45
- this._onCtx = this.handleContextMenu.bind(this);
46
- this._onDocClick = this.hideContextMenu.bind(this);
47
- this._onKeyDown = this.handleKeyDown.bind(this);
48
40
  this._onRateChange = this.enforceMaxRate.bind(this);
49
- this._onSelectStart = (e) => e.preventDefault();
50
- this._onDragStart = (e) => e.preventDefault();
51
41
  this.videoElement = videoElement;
52
- this.container = videoElement.parentElement;
53
42
  this.config = {
54
43
  embedTokenEndpoint: config.embedTokenEndpoint,
55
44
  apiBaseUrl: config.apiBaseUrl || '',
@@ -69,6 +58,7 @@ class GuardVideoPlayer {
69
58
  onError: config.onError || (() => { }),
70
59
  onQualityChange: config.onQualityChange || (() => { }),
71
60
  onStateChange: config.onStateChange || (() => { }),
61
+ onWatermark: config.onWatermark || (() => { }),
72
62
  };
73
63
  this.log('Initializing GuardVideo Player', { videoId, config });
74
64
  if (!this.checkAllowedDomain())
@@ -78,17 +68,17 @@ class GuardVideoPlayer {
78
68
  }
79
69
  log(message, data) {
80
70
  if (this.config.debug) {
81
- console.log(`[GuardVideoPlayer] ${message}`, data || '');
71
+ console.log('[GuardVideoPlayer] ' + message, data || '');
82
72
  }
83
73
  }
84
74
  error(message, data) {
85
- console.error(`[GuardVideoPlayer] ${message}`, data || '');
75
+ console.error('[GuardVideoPlayer] ' + message, data || '');
86
76
  }
87
77
  setState(newState) {
88
78
  if (this.state !== newState) {
89
79
  this.state = newState;
90
80
  this.config.onStateChange(newState);
91
- this.log(`State changed to: ${newState}`);
81
+ this.log('State changed to: ' + newState);
92
82
  }
93
83
  }
94
84
  checkAllowedDomain() {
@@ -96,11 +86,11 @@ class GuardVideoPlayer {
96
86
  if (!domains || domains.length === 0)
97
87
  return true;
98
88
  const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '';
99
- const allowed = domains.some((d) => currentOrigin === d || currentOrigin.endsWith(`.${d.replace(/^https?:\/\//, '')}`));
89
+ const allowed = domains.some((d) => currentOrigin === d || currentOrigin.endsWith('.' + d.replace(/^https?:\/\//, '')));
100
90
  if (!allowed) {
101
91
  this.handleError({
102
92
  code: 'DOMAIN_NOT_ALLOWED',
103
- message: `This player is not authorized to run on ${currentOrigin}`,
93
+ message: 'This player is not authorized to run on ' + currentOrigin,
104
94
  fatal: true,
105
95
  });
106
96
  return false;
@@ -109,294 +99,24 @@ class GuardVideoPlayer {
109
99
  }
110
100
  applySecurity() {
111
101
  const sec = this.config.security;
112
- const target = this.container || this.videoElement;
113
- target.addEventListener('contextmenu', this._onCtx);
114
- document.addEventListener('click', this._onDocClick);
115
- if (sec.disableSelection) {
116
- target.addEventListener('selectstart', this._onSelectStart);
117
- target.style.userSelect = 'none';
118
- target.style.webkitUserSelect = 'none';
119
- }
120
- if (sec.disableDrag) {
121
- this.videoElement.addEventListener('dragstart', this._onDragStart);
122
- this.videoElement.draggable = false;
123
- }
124
102
  if (sec.disablePiP) {
125
103
  this.videoElement.disablePictureInPicture = true;
126
104
  }
127
105
  if (sec.disableScreenCapture) {
128
- if ('mediaKeys' in this.videoElement && typeof navigator.requestMediaKeySystemAccess === 'function') {
106
+ if ('mediaKeys' in this.videoElement &&
107
+ typeof navigator.requestMediaKeySystemAccess === 'function') {
129
108
  this.log('Screen-capture protection: EME hint applied');
130
109
  }
131
- target.style.setProperty('-webkit-app-region', 'no-drag');
132
- }
133
- if (sec.blockDevTools) {
134
- document.addEventListener('keydown', this._onKeyDown);
135
110
  }
136
111
  if (sec.maxPlaybackRate) {
137
112
  this.videoElement.addEventListener('ratechange', this._onRateChange);
138
113
  }
139
- if (sec.enableWatermark && sec.watermarkText && this.container) {
140
- this.createWatermark(sec.watermarkText);
141
- }
142
- this.injectProtectiveStyles();
143
- }
144
- handleContextMenu(e) {
145
- e.preventDefault();
146
- e.stopPropagation();
147
- const sec = this.config.security;
148
- if (sec.disableRightClick)
149
- return;
150
- const me = e;
151
- this.showContextMenu(me.clientX, me.clientY);
152
- }
153
- showContextMenu(x, y) {
154
- this.hideContextMenu();
155
- const branding = this.config.branding;
156
- const extraItems = this.config.contextMenuItems;
157
- const menu = document.createElement('div');
158
- menu.className = 'gv-ctx-menu';
159
- menu.setAttribute('role', 'menu');
160
- const header = document.createElement('a');
161
- header.className = 'gv-ctx-header';
162
- header.href = branding.url;
163
- header.target = '_blank';
164
- header.rel = 'noopener noreferrer';
165
- header.setAttribute('role', 'menuitem');
166
- if (branding.logoUrl) {
167
- const logo = document.createElement('img');
168
- logo.src = branding.logoUrl;
169
- logo.alt = branding.name;
170
- logo.className = 'gv-ctx-logo';
171
- logo.width = 20;
172
- logo.height = 20;
173
- header.appendChild(logo);
174
- }
175
- else {
176
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
177
- svg.setAttribute('width', '18');
178
- svg.setAttribute('height', '18');
179
- svg.setAttribute('viewBox', '0 0 24 24');
180
- svg.setAttribute('fill', 'none');
181
- svg.setAttribute('stroke', branding.accentColor);
182
- svg.setAttribute('stroke-width', '2');
183
- svg.setAttribute('stroke-linecap', 'round');
184
- svg.setAttribute('stroke-linejoin', 'round');
185
- svg.innerHTML = '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>';
186
- header.appendChild(svg);
187
- }
188
- const nameSpan = document.createElement('span');
189
- nameSpan.className = 'gv-ctx-brand-name';
190
- nameSpan.textContent = branding.name;
191
- header.appendChild(nameSpan);
192
- const tagSpan = document.createElement('span');
193
- tagSpan.className = 'gv-ctx-tag';
194
- tagSpan.textContent = 'Secure Video Player';
195
- header.appendChild(tagSpan);
196
- menu.appendChild(header);
197
- if (extraItems.length > 0) {
198
- extraItems.forEach((item) => {
199
- if (item.separator) {
200
- const sep = document.createElement('div');
201
- sep.className = 'gv-ctx-sep';
202
- menu.appendChild(sep);
203
- }
204
- const row = document.createElement('div');
205
- row.className = 'gv-ctx-item';
206
- row.setAttribute('role', 'menuitem');
207
- row.tabIndex = 0;
208
- if (item.icon) {
209
- const ico = document.createElement('img');
210
- ico.src = item.icon;
211
- ico.width = 14;
212
- ico.height = 14;
213
- ico.className = 'gv-ctx-item-icon';
214
- row.appendChild(ico);
215
- }
216
- const label = document.createElement('span');
217
- label.textContent = item.label;
218
- row.appendChild(label);
219
- row.addEventListener('click', (ev) => {
220
- ev.stopPropagation();
221
- this.hideContextMenu();
222
- if (item.onClick) {
223
- item.onClick();
224
- }
225
- else if (item.href) {
226
- window.open(item.href, '_blank', 'noopener,noreferrer');
227
- }
228
- });
229
- menu.appendChild(row);
230
- });
231
- }
232
- const sep = document.createElement('div');
233
- sep.className = 'gv-ctx-sep';
234
- menu.appendChild(sep);
235
- const version = document.createElement('div');
236
- version.className = 'gv-ctx-version';
237
- version.textContent = `${branding.name} Player v1.0`;
238
- menu.appendChild(version);
239
- document.body.appendChild(menu);
240
- const rect = menu.getBoundingClientRect();
241
- const vw = window.innerWidth;
242
- const vh = window.innerHeight;
243
- menu.style.left = `${x + rect.width > vw ? vw - rect.width - 8 : x}px`;
244
- menu.style.top = `${y + rect.height > vh ? vh - rect.height - 8 : y}px`;
245
- this.ctxMenu = menu;
246
- }
247
- hideContextMenu() {
248
- if (this.ctxMenu) {
249
- this.ctxMenu.remove();
250
- this.ctxMenu = null;
251
- }
252
- }
253
- injectProtectiveStyles() {
254
- if (this.ctxStyleTag)
255
- return;
256
- const branding = this.config.branding;
257
- const accent = branding.accentColor;
258
- const css = `
259
- /* GuardVideo branded context menu */
260
- .gv-ctx-menu {
261
- position: fixed;
262
- z-index: 2147483647;
263
- min-width: 220px;
264
- background: rgba(18, 18, 22, 0.96);
265
- backdrop-filter: blur(12px);
266
- -webkit-backdrop-filter: blur(12px);
267
- border: 1px solid rgba(255,255,255,0.08);
268
- border-radius: 10px;
269
- padding: 6px 0;
270
- box-shadow: 0 8px 32px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.04);
271
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
272
- font-size: 13px;
273
- color: #e4e4e7;
274
- user-select: none;
275
- animation: gv-ctx-in 0.12s ease-out;
276
- }
277
- @keyframes gv-ctx-in {
278
- from { opacity: 0; transform: scale(0.96); }
279
- to { opacity: 1; transform: scale(1); }
280
- }
281
-
282
- .gv-ctx-header {
283
- display: flex;
284
- align-items: center;
285
- gap: 8px;
286
- padding: 8px 14px 8px 12px;
287
- text-decoration: none;
288
- color: inherit;
289
- transition: background 0.15s;
290
- border-radius: 6px 6px 0 0;
291
- }
292
- .gv-ctx-header:hover { background: rgba(255,255,255,0.06); }
293
-
294
- .gv-ctx-logo { border-radius: 4px; }
295
-
296
- .gv-ctx-brand-name {
297
- font-weight: 600;
298
- color: ${accent};
299
- white-space: nowrap;
300
- }
301
-
302
- .gv-ctx-tag {
303
- margin-left: auto;
304
- font-size: 10px;
305
- color: rgba(255,255,255,0.35);
306
- white-space: nowrap;
307
- }
308
-
309
- .gv-ctx-sep {
310
- height: 1px;
311
- margin: 4px 10px;
312
- background: rgba(255,255,255,0.07);
313
- }
314
-
315
- .gv-ctx-item {
316
- display: flex;
317
- align-items: center;
318
- gap: 8px;
319
- padding: 7px 14px 7px 12px;
320
- cursor: pointer;
321
- transition: background 0.15s;
322
- }
323
- .gv-ctx-item:hover { background: rgba(255,255,255,0.06); }
324
- .gv-ctx-item-icon { border-radius: 2px; }
325
-
326
- .gv-ctx-version {
327
- padding: 4px 14px 6px 12px;
328
- font-size: 10px;
329
- color: rgba(255,255,255,0.25);
330
- }
331
-
332
- /* Watermark overlay */
333
- .gv-watermark {
334
- position: absolute;
335
- inset: 0;
336
- pointer-events: none;
337
- overflow: hidden;
338
- z-index: 10;
339
- }
340
- .gv-watermark-text {
341
- position: absolute;
342
- white-space: nowrap;
343
- font-size: 14px;
344
- font-family: monospace;
345
- color: rgba(255,255,255,0.07);
346
- transform: rotate(-30deg);
347
- user-select: none;
348
- pointer-events: none;
349
- }
350
- `;
351
- const tag = document.createElement('style');
352
- tag.setAttribute('data-guardvideo', 'player-styles');
353
- tag.textContent = css;
354
- document.head.appendChild(tag);
355
- this.ctxStyleTag = tag;
356
- }
357
- createWatermark(text) {
358
- if (!this.container)
359
- return;
360
- const overlay = document.createElement('div');
361
- overlay.className = 'gv-watermark';
362
- for (let row = 0; row < 5; row++) {
363
- for (let col = 0; col < 4; col++) {
364
- const span = document.createElement('span');
365
- span.className = 'gv-watermark-text';
366
- span.textContent = text;
367
- span.style.left = `${col * 28 + (row % 2) * 14}%`;
368
- span.style.top = `${row * 22}%`;
369
- overlay.appendChild(span);
370
- }
371
- }
372
- this.container.style.position = 'relative';
373
- this.container.appendChild(overlay);
374
- this.watermarkEl = overlay;
375
- this.watermarkObserver = new MutationObserver(() => {
376
- if (this.container && this.watermarkEl && !this.container.contains(this.watermarkEl)) {
377
- this.container.appendChild(this.watermarkEl);
378
- }
379
- });
380
- this.watermarkObserver.observe(this.container, { childList: true, subtree: false });
381
- }
382
- handleKeyDown(e) {
383
- if (e.key === 'F12') {
384
- e.preventDefault();
385
- return;
386
- }
387
- if (e.ctrlKey && e.shiftKey && ['I', 'J', 'C'].includes(e.key.toUpperCase())) {
388
- e.preventDefault();
389
- return;
390
- }
391
- if (e.ctrlKey && e.key.toUpperCase() === 'U') {
392
- e.preventDefault();
393
- }
394
114
  }
395
115
  enforceMaxRate() {
396
116
  const max = this.config.security.maxPlaybackRate;
397
117
  if (this.videoElement.playbackRate > max) {
398
118
  this.videoElement.playbackRate = max;
399
- this.log(`Playback rate clamped to ${max}`);
119
+ this.log('Playback rate clamped to ' + max);
400
120
  }
401
121
  }
402
122
  async initialize() {
@@ -421,38 +141,38 @@ class GuardVideoPlayer {
421
141
  return;
422
142
  try {
423
143
  const tokenId = this.embedToken.tokenId;
424
- const url = `${this.config.apiBaseUrl}/videos/stream/${this.videoId}/viewer-config?token=${encodeURIComponent(tokenId)}`;
144
+ const url = this.config.apiBaseUrl +
145
+ '/videos/stream/' + this.videoId +
146
+ '/viewer-config?token=' + encodeURIComponent(tokenId);
425
147
  const resp = await fetch(url, { credentials: 'omit' });
426
148
  if (!resp.ok) {
427
149
  this.log('viewer-config fetch failed, falling back to SDK config', resp.status);
428
150
  const sec = this.config.security;
429
- if (sec.enableWatermark && sec.watermarkText && this.container) {
430
- this.createWatermark(sec.watermarkText);
151
+ if (sec.enableWatermark && sec.watermarkText) {
152
+ this.config.onWatermark?.(sec.watermarkText);
431
153
  }
432
154
  return;
433
155
  }
434
156
  const cfg = await resp.json();
435
157
  this.log('Watermark config from server:', cfg);
436
- if (cfg.enableWatermark && cfg.watermarkText && this.container) {
437
- this.createWatermark(cfg.watermarkText);
158
+ if (cfg.enableWatermark && cfg.watermarkText) {
159
+ this.config.onWatermark?.(cfg.watermarkText);
438
160
  }
439
161
  }
440
162
  catch (err) {
441
163
  this.log('fetchAndApplyWatermark error (non-fatal):', err);
442
164
  const sec = this.config.security;
443
- if (sec.enableWatermark && sec.watermarkText && this.container) {
444
- this.createWatermark(sec.watermarkText);
165
+ if (sec.enableWatermark && sec.watermarkText) {
166
+ this.config.onWatermark?.(sec.watermarkText);
445
167
  }
446
168
  }
447
169
  }
448
170
  async fetchEmbedToken() {
449
- const url = `${this.config.embedTokenEndpoint}/${this.videoId}`;
171
+ const url = this.config.embedTokenEndpoint + '/' + this.videoId;
450
172
  this.log('Fetching embed token from', url);
451
173
  const response = await fetch(url, {
452
174
  method: 'POST',
453
- headers: {
454
- 'Content-Type': 'application/json',
455
- },
175
+ headers: { 'Content-Type': 'application/json' },
456
176
  body: JSON.stringify({
457
177
  allowedDomain: window.location.origin,
458
178
  expiresInMinutes: 120,
@@ -504,14 +224,11 @@ class GuardVideoPlayer {
504
224
  this.hls.loadSource(playerUrl);
505
225
  this.hls.attachMedia(this.videoElement);
506
226
  this.hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => {
507
- this.log('HLS manifest parsed', {
508
- levels: data.levels.map((l) => `${l.height}p`),
509
- });
227
+ this.log('HLS manifest parsed', { levels: data.levels.map((l) => l.height + 'p') });
510
228
  this.setState(PlayerState.READY);
511
229
  this.config.onReady();
512
- if (this.config.autoplay) {
230
+ if (this.config.autoplay)
513
231
  this.play();
514
- }
515
232
  });
516
233
  this.hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => {
517
234
  const level = this.hls.levels[data.level];
@@ -520,10 +237,10 @@ class GuardVideoPlayer {
520
237
  height: level.height,
521
238
  width: level.width,
522
239
  bitrate: level.bitrate,
523
- name: `${level.height}p`,
240
+ name: level.height + 'p',
524
241
  };
525
242
  this.currentQuality = quality;
526
- this.log(`Quality switched to ${quality.name}`);
243
+ this.log('Quality switched to ' + quality.name);
527
244
  this.config.onQualityChange(quality.name);
528
245
  });
529
246
  this.hls.on(Hls.Events.ERROR, (_event, data) => {
@@ -539,40 +256,28 @@ class GuardVideoPlayer {
539
256
  this.hls?.recoverMediaError();
540
257
  break;
541
258
  default:
542
- this.handleError({
543
- code: data.type,
544
- message: data.details,
545
- fatal: true,
546
- details: data,
547
- });
548
- break;
259
+ this.handleError({ code: data.type, message: data.details, fatal: true, details: data });
549
260
  }
550
261
  }
551
262
  });
552
263
  this.setupVideoEventListeners();
553
264
  }
554
265
  setupVideoEventListeners() {
555
- this.videoElement.addEventListener('playing', () => {
556
- this.setState(PlayerState.PLAYING);
557
- });
558
- this.videoElement.addEventListener('pause', () => {
559
- this.setState(PlayerState.PAUSED);
560
- });
561
- this.videoElement.addEventListener('waiting', () => {
562
- this.setState(PlayerState.BUFFERING);
563
- });
266
+ this.videoElement.addEventListener('playing', () => this.setState(PlayerState.PLAYING));
267
+ this.videoElement.addEventListener('pause', () => this.setState(PlayerState.PAUSED));
268
+ this.videoElement.addEventListener('waiting', () => this.setState(PlayerState.BUFFERING));
564
269
  this.videoElement.addEventListener('error', () => {
565
270
  const error = this.videoElement.error;
566
271
  if (error) {
567
- const errorMessages = {
272
+ const msgs = {
568
273
  1: 'Video loading aborted',
569
274
  2: 'Network error',
570
275
  3: 'Video decoding failed',
571
276
  4: 'Video format not supported',
572
277
  };
573
278
  this.handleError({
574
- code: `MEDIA_ERROR_${error.code}`,
575
- message: errorMessages[error.code] || 'Unknown media error',
279
+ code: 'MEDIA_ERROR_' + error.code,
280
+ message: msgs[error.code] || 'Unknown media error',
576
281
  fatal: true,
577
282
  details: error,
578
283
  });
@@ -593,21 +298,11 @@ class GuardVideoPlayer {
593
298
  throw err;
594
299
  }
595
300
  }
596
- pause() {
597
- this.videoElement.pause();
598
- }
599
- getCurrentTime() {
600
- return this.videoElement.currentTime;
601
- }
602
- seek(time) {
603
- this.videoElement.currentTime = time;
604
- }
605
- getDuration() {
606
- return this.videoElement.duration || 0;
607
- }
608
- getVolume() {
609
- return this.videoElement.volume;
610
- }
301
+ pause() { this.videoElement.pause(); }
302
+ getCurrentTime() { return this.videoElement.currentTime; }
303
+ seek(time) { this.videoElement.currentTime = time; }
304
+ getDuration() { return this.videoElement.duration || 0; }
305
+ getVolume() { return this.videoElement.volume; }
611
306
  setVolume(volume) {
612
307
  this.videoElement.volume = Math.max(0, Math.min(1, volume));
613
308
  }
@@ -619,34 +314,20 @@ class GuardVideoPlayer {
619
314
  height: level.height,
620
315
  width: level.width,
621
316
  bitrate: level.bitrate,
622
- name: `${level.height}p`,
317
+ name: level.height + 'p',
623
318
  }));
624
319
  }
625
- getCurrentQuality() {
626
- return this.currentQuality;
627
- }
320
+ getCurrentQuality() { return this.currentQuality; }
628
321
  setQuality(levelIndex) {
629
322
  if (this.hls) {
630
323
  this.hls.currentLevel = levelIndex;
631
- this.log(`Quality set to level ${levelIndex}`);
324
+ this.log('Quality set to level ' + levelIndex);
632
325
  }
633
326
  }
634
- getState() {
635
- return this.state;
636
- }
327
+ getState() { return this.state; }
637
328
  destroy() {
638
329
  this.log('Destroying player');
639
- const target = this.container || this.videoElement;
640
- target.removeEventListener('contextmenu', this._onCtx);
641
- target.removeEventListener('selectstart', this._onSelectStart);
642
- this.videoElement.removeEventListener('dragstart', this._onDragStart);
643
330
  this.videoElement.removeEventListener('ratechange', this._onRateChange);
644
- document.removeEventListener('click', this._onDocClick);
645
- document.removeEventListener('keydown', this._onKeyDown);
646
- this.hideContextMenu();
647
- this.watermarkObserver?.disconnect();
648
- this.watermarkEl?.remove();
649
- this.ctxStyleTag?.remove();
650
331
  if (this.hls) {
651
332
  this.hls.destroy();
652
333
  this.hls = null;
@@ -733,6 +414,10 @@ function injectStyles() {
733
414
  -ms-user-select: none;
734
415
  user-select: none;
735
416
  outline: none;
417
+ /* Reserve space at the bottom so the video is never covered by the controls bar.
418
+ Controls bar ≈ 10px top padding + seek(~20px) + btn row(~34px) + 14px bottom = ~88px.
419
+ We add this as padding-bottom so the video shrinks up rather than sitting behind the bar. */
420
+ padding-bottom: 90px;
736
421
 
737
422
  /* Subtle inner vignette for cinema depth */
738
423
  box-shadow:
@@ -1046,6 +731,7 @@ function injectStyles() {
1046
731
  width: 0;
1047
732
  max-width: 72px;
1048
733
  height: 3px;
734
+ /* Default background — overridden by JS inline style for the fill gradient */
1049
735
  background: rgba(255,255,255,0.18);
1050
736
  border-radius: 99px;
1051
737
  outline: none;
@@ -1063,6 +749,12 @@ function injectStyles() {
1063
749
  width: 72px;
1064
750
  opacity: 1;
1065
751
  }
752
+ /* WebKit runnable track — gradient is set via inline style by JS */
753
+ .gvp-volume-slider::-webkit-slider-runnable-track {
754
+ height: 3px;
755
+ border-radius: 99px;
756
+ background: inherit; /* picks up the JS inline style gradient */
757
+ }
1066
758
  /* WebKit thumb */
1067
759
  .gvp-volume-slider::-webkit-slider-thumb {
1068
760
  -webkit-appearance: none;
@@ -1070,9 +762,16 @@ function injectStyles() {
1070
762
  border-radius: 50%;
1071
763
  background: #fff;
1072
764
  cursor: pointer;
765
+ margin-top: -4.5px; /* vertically centre over the 3px track */
1073
766
  -webkit-box-shadow: 0 1px 4px rgba(0,0,0,0.4);
1074
767
  box-shadow: 0 1px 4px rgba(0,0,0,0.4);
1075
768
  }
769
+ /* Firefox — native filled track */
770
+ .gvp-volume-slider::-moz-range-progress {
771
+ background: var(--gvp-accent);
772
+ border-radius: 99px;
773
+ height: 3px;
774
+ }
1076
775
  /* Firefox thumb */
1077
776
  .gvp-volume-slider::-moz-range-thumb {
1078
777
  width: 12px; height: 12px;
@@ -1346,6 +1045,81 @@ function injectStyles() {
1346
1045
  .gvp-time { font-size: 11px; }
1347
1046
  .gvp-controls-inner { padding: 8px 10px; }
1348
1047
  }
1048
+
1049
+ /* ── Branded context menu ─────────────────────────────────────── */
1050
+ .gvp-ctx-menu {
1051
+ position: fixed;
1052
+ z-index: 2147483647;
1053
+ min-width: 220px;
1054
+ background: rgba(12,12,18,0.96);
1055
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
1056
+ backdrop-filter: blur(16px) saturate(180%);
1057
+ border: 1px solid rgba(255,255,255,0.08);
1058
+ border-radius: 12px;
1059
+ padding: 6px 0;
1060
+ -webkit-box-shadow: 0 12px 40px rgba(0,0,0,0.55), 0 0 0 0.5px rgba(255,255,255,0.04);
1061
+ box-shadow: 0 12px 40px rgba(0,0,0,0.55), 0 0 0 0.5px rgba(255,255,255,0.04);
1062
+ font-family: var(--gvp-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
1063
+ font-size: 13px;
1064
+ color: rgba(255,255,255,0.9);
1065
+ -webkit-user-select: none;
1066
+ -moz-user-select: none;
1067
+ -ms-user-select: none;
1068
+ user-select: none;
1069
+ -webkit-animation: gvp-ctx-in 0.12s cubic-bezier(0.22,1,0.36,1);
1070
+ animation: gvp-ctx-in 0.12s cubic-bezier(0.22,1,0.36,1);
1071
+ }
1072
+ @-webkit-keyframes gvp-ctx-in {
1073
+ from { opacity: 0; -webkit-transform: scale(0.95); transform: scale(0.95); }
1074
+ to { opacity: 1; -webkit-transform: none; transform: none; }
1075
+ }
1076
+ @keyframes gvp-ctx-in {
1077
+ from { opacity: 0; transform: scale(0.95); }
1078
+ to { opacity: 1; transform: none; }
1079
+ }
1080
+ .gvp-ctx-header {
1081
+ display: -webkit-box; display: -ms-flexbox; display: flex;
1082
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1083
+ gap: 8px;
1084
+ padding: 8px 14px 8px 12px;
1085
+ text-decoration: none;
1086
+ color: inherit;
1087
+ -webkit-transition: background 0.15s; transition: background 0.15s;
1088
+ border-radius: 8px 8px 0 0;
1089
+ }
1090
+ .gvp-ctx-header:hover { background: rgba(255,255,255,0.06); }
1091
+ .gvp-ctx-logo { border-radius: 4px; }
1092
+ .gvp-ctx-brand-name {
1093
+ font-weight: 600;
1094
+ color: var(--gvp-accent);
1095
+ white-space: nowrap;
1096
+ }
1097
+ .gvp-ctx-tag {
1098
+ margin-left: auto;
1099
+ font-size: 10px;
1100
+ color: rgba(255,255,255,0.3);
1101
+ white-space: nowrap;
1102
+ }
1103
+ .gvp-ctx-sep {
1104
+ height: 1px;
1105
+ margin: 4px 10px;
1106
+ background: rgba(255,255,255,0.07);
1107
+ }
1108
+ .gvp-ctx-item {
1109
+ display: -webkit-box; display: -ms-flexbox; display: flex;
1110
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1111
+ gap: 8px;
1112
+ padding: 7px 14px 7px 12px;
1113
+ cursor: pointer;
1114
+ -webkit-transition: background 0.15s; transition: background 0.15s;
1115
+ }
1116
+ .gvp-ctx-item:hover { background: rgba(255,255,255,0.06); }
1117
+ .gvp-ctx-item-icon { border-radius: 2px; }
1118
+ .gvp-ctx-version {
1119
+ padding: 4px 14px 6px 12px;
1120
+ font-size: 10px;
1121
+ color: rgba(255,255,255,0.25);
1122
+ }
1349
1123
  `;
1350
1124
  const tag = document.createElement('style');
1351
1125
  tag.setAttribute('data-guardvideo', 'player-ui-styles-v2');
@@ -1363,6 +1137,12 @@ class PlayerUI {
1363
1137
  this.openMenu = null;
1364
1138
  this.hideTimer = null;
1365
1139
  this.seekDragging = false;
1140
+ this._ctxMenu = null;
1141
+ this._ctxDocClickBound = () => { };
1142
+ this._ctxTargetBound = () => { };
1143
+ this._ctxKeyDownBound = () => { };
1144
+ this._watermarkObserver = null;
1145
+ this._watermarkText = '';
1366
1146
  const accent = config.branding?.accentColor ?? '#00e5a0';
1367
1147
  const brandName = config.branding?.name ?? 'GuardVideo';
1368
1148
  injectStyles();
@@ -1510,11 +1290,13 @@ class PlayerUI {
1510
1290
  };
1511
1291
  this._seekTouchEndBound = () => this._endSeekDrag();
1512
1292
  this._wireEvents(videoId, config);
1293
+ requestAnimationFrame(() => this._updateVolSliderFill(1));
1513
1294
  if (config.forensicWatermark !== false) {
1514
1295
  const wmText = config.viewerEmail || config.viewerName || '';
1515
1296
  if (wmText)
1516
1297
  this._renderWatermark(wmText);
1517
1298
  }
1299
+ this._applySecurity(config);
1518
1300
  }
1519
1301
  _hexToRgba(hex, alpha) {
1520
1302
  const clean = hex.replace('#', '');
@@ -1563,6 +1345,7 @@ class PlayerUI {
1563
1345
  this._onStateChange(state);
1564
1346
  config.onStateChange?.(state);
1565
1347
  },
1348
+ onWatermark: (text) => this._renderWatermark(text),
1566
1349
  });
1567
1350
  video.addEventListener('timeupdate', () => {
1568
1351
  config.onTimeUpdate?.(video.currentTime);
@@ -1598,6 +1381,7 @@ class PlayerUI {
1598
1381
  this.volSlider.addEventListener('input', () => {
1599
1382
  video.volume = parseFloat(this.volSlider.value);
1600
1383
  video.muted = video.volume === 0;
1384
+ this._updateVolSliderFill(video.volume);
1601
1385
  });
1602
1386
  this.seekWrap.addEventListener('mousedown', (e) => {
1603
1387
  e.preventDefault();
@@ -1715,6 +1499,14 @@ class PlayerUI {
1715
1499
  this.playBtn.title = `${label} (k)`;
1716
1500
  }
1717
1501
  _toggleMute() { this.videoEl.muted = !this.videoEl.muted; }
1502
+ _updateVolSliderFill(vol) {
1503
+ const accent = getComputedStyle(this.root).getPropertyValue('--gvp-accent').trim() || '#00e5a0';
1504
+ const pct = Math.round(vol * 100);
1505
+ this.volSlider.style.background =
1506
+ `linear-gradient(to right,` +
1507
+ ` ${accent} 0%, ${accent} ${pct}%,` +
1508
+ ` rgba(255,255,255,0.18) ${pct}%, rgba(255,255,255,0.18) 100%)`;
1509
+ }
1718
1510
  _onVolumeChange() {
1719
1511
  const v = this.videoEl;
1720
1512
  const vol = v.muted ? 0 : v.volume;
@@ -1725,6 +1517,7 @@ class PlayerUI {
1725
1517
  this.volBtn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
1726
1518
  this.volBtn.title = muted ? 'Unmute (m)' : 'Mute (m)';
1727
1519
  this.volSlider.value = String(vol);
1520
+ this._updateVolSliderFill(vol);
1728
1521
  }
1729
1522
  _startSeekDrag() {
1730
1523
  this.seekDragging = true;
@@ -1911,6 +1704,9 @@ class PlayerUI {
1911
1704
  this.controls.classList.add('gvp-hidden');
1912
1705
  }
1913
1706
  _renderWatermark(text) {
1707
+ if (!text)
1708
+ return;
1709
+ this._watermarkText = text;
1914
1710
  this.watermarkDiv.innerHTML = '';
1915
1711
  for (let i = 0; i < 20; i++) {
1916
1712
  const span = el('span', 'gvp-watermark-text');
@@ -1919,6 +1715,40 @@ class PlayerUI {
1919
1715
  span.style.top = `${Math.floor(i / 4) * 22}%`;
1920
1716
  this.watermarkDiv.appendChild(span);
1921
1717
  }
1718
+ this._mountWatermarkObserver();
1719
+ }
1720
+ _mountWatermarkObserver() {
1721
+ this._watermarkObserver?.disconnect();
1722
+ this._watermarkObserver = new MutationObserver((mutations) => {
1723
+ for (const m of mutations) {
1724
+ if (m.type === 'childList' && m.target === this.root) {
1725
+ if (!this.root.contains(this.watermarkDiv)) {
1726
+ this.root.appendChild(this.watermarkDiv);
1727
+ }
1728
+ }
1729
+ if (m.type === 'attributes' && m.target === this.watermarkDiv) {
1730
+ this.watermarkDiv.removeAttribute('style');
1731
+ this.watermarkDiv.className = 'gvp-watermark';
1732
+ this.watermarkDiv.setAttribute('aria-hidden', 'true');
1733
+ }
1734
+ if (m.type === 'childList' && m.target === this.watermarkDiv) {
1735
+ if (this.watermarkDiv.childElementCount < 20) {
1736
+ this._refillWatermark();
1737
+ }
1738
+ }
1739
+ }
1740
+ });
1741
+ this._watermarkObserver.observe(this.root, { childList: true });
1742
+ this._watermarkObserver.observe(this.watermarkDiv, { attributes: true, childList: true });
1743
+ }
1744
+ _refillWatermark() {
1745
+ for (let i = this.watermarkDiv.childElementCount; i < 20; i++) {
1746
+ const span = el('span', 'gvp-watermark-text');
1747
+ 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}%`;
1750
+ this.watermarkDiv.appendChild(span);
1751
+ }
1922
1752
  }
1923
1753
  _addRipple(e) {
1924
1754
  const rect = this.root.getBoundingClientRect();
@@ -1966,6 +1796,120 @@ class PlayerUI {
1966
1796
  break;
1967
1797
  }
1968
1798
  }
1799
+ _applySecurity(config) {
1800
+ const sec = config.security;
1801
+ this.root.style.userSelect = 'none';
1802
+ this.root.style.webkitUserSelect = 'none';
1803
+ this.root.style.msUserSelect = 'none';
1804
+ if (sec?.disableDrag !== false) {
1805
+ this.videoEl.draggable = false;
1806
+ this.videoEl.addEventListener('dragstart', (e) => e.preventDefault());
1807
+ }
1808
+ if (sec?.blockDevTools) {
1809
+ this._ctxKeyDownBound = (e) => {
1810
+ if (e.key === 'F12') {
1811
+ e.preventDefault();
1812
+ return;
1813
+ }
1814
+ if (e.ctrlKey && e.shiftKey && ['I', 'J', 'C'].includes(e.key.toUpperCase())) {
1815
+ e.preventDefault();
1816
+ return;
1817
+ }
1818
+ if (e.ctrlKey && e.key.toUpperCase() === 'U')
1819
+ e.preventDefault();
1820
+ };
1821
+ document.addEventListener('keydown', this._ctxKeyDownBound);
1822
+ }
1823
+ this._ctxDocClickBound = () => this._hideContextMenu();
1824
+ document.addEventListener('click', this._ctxDocClickBound);
1825
+ this._ctxTargetBound = (e) => {
1826
+ e.preventDefault();
1827
+ e.stopPropagation();
1828
+ if (sec?.disableRightClick)
1829
+ return;
1830
+ const me = e;
1831
+ this._showContextMenu(me.clientX, me.clientY, config);
1832
+ };
1833
+ this.root.addEventListener('contextmenu', this._ctxTargetBound);
1834
+ }
1835
+ _showContextMenu(x, y, config) {
1836
+ this._hideContextMenu();
1837
+ const br = config.branding;
1838
+ const name = br?.name ?? 'GuardVideo';
1839
+ const url = br?.url ?? 'https://guardvid.com';
1840
+ const logoUrl = br?.logoUrl ?? '';
1841
+ const accent = br?.accentColor ?? '#00e5a0';
1842
+ const extras = config.contextMenuItems ?? [];
1843
+ const menu = el('div', 'gvp-ctx-menu');
1844
+ menu.setAttribute('role', 'menu');
1845
+ menu.style.setProperty('--gvp-accent', accent);
1846
+ const header = document.createElement('a');
1847
+ header.className = 'gvp-ctx-header';
1848
+ header.href = url;
1849
+ header.target = '_blank';
1850
+ header.rel = 'noopener noreferrer';
1851
+ header.setAttribute('role', 'menuitem');
1852
+ if (logoUrl) {
1853
+ const logo = el('img', 'gvp-ctx-logo');
1854
+ logo.src = logoUrl;
1855
+ logo.alt = name;
1856
+ logo.width = 20;
1857
+ logo.height = 20;
1858
+ header.appendChild(logo);
1859
+ }
1860
+ else {
1861
+ header.appendChild(svgEl(ICON.shield, 18, 18));
1862
+ }
1863
+ const nameSpan = el('span', 'gvp-ctx-brand-name');
1864
+ nameSpan.textContent = name;
1865
+ const tagSpan = el('span', 'gvp-ctx-tag');
1866
+ tagSpan.textContent = 'Secure Video Player';
1867
+ header.append(nameSpan, tagSpan);
1868
+ menu.appendChild(header);
1869
+ extras.forEach((item) => {
1870
+ if (item.separator) {
1871
+ menu.appendChild(el('div', 'gvp-ctx-sep'));
1872
+ return;
1873
+ }
1874
+ const row = el('div', 'gvp-ctx-item');
1875
+ row.setAttribute('role', 'menuitem');
1876
+ row.setAttribute('tabindex', '0');
1877
+ if (item.icon) {
1878
+ const ico = el('img', 'gvp-ctx-item-icon');
1879
+ ico.src = item.icon;
1880
+ ico.width = 14;
1881
+ ico.height = 14;
1882
+ row.appendChild(ico);
1883
+ }
1884
+ const lbl = el('span');
1885
+ lbl.textContent = item.label;
1886
+ row.appendChild(lbl);
1887
+ row.addEventListener('click', (ev) => {
1888
+ ev.stopPropagation();
1889
+ this._hideContextMenu();
1890
+ if (item.onClick)
1891
+ item.onClick();
1892
+ else if (item.href)
1893
+ window.open(item.href, '_blank', 'noopener,noreferrer');
1894
+ });
1895
+ menu.appendChild(row);
1896
+ });
1897
+ menu.appendChild(el('div', 'gvp-ctx-sep'));
1898
+ const ver = el('div', 'gvp-ctx-version');
1899
+ ver.textContent = `${name} Player v1.0`;
1900
+ menu.appendChild(ver);
1901
+ document.body.appendChild(menu);
1902
+ const rect = menu.getBoundingClientRect();
1903
+ menu.style.left = `${x + rect.width > window.innerWidth ? window.innerWidth - rect.width - 8 : x}px`;
1904
+ menu.style.top = `${y + rect.height > window.innerHeight ? window.innerHeight - rect.height - 8 : y}px`;
1905
+ this._ctxMenu = menu;
1906
+ }
1907
+ _hideContextMenu() {
1908
+ if (this._ctxMenu) {
1909
+ this._ctxMenu.remove();
1910
+ this._ctxMenu = null;
1911
+ }
1912
+ }
1969
1913
  play() { return this.corePlayer.play(); }
1970
1914
  pause() { return this.corePlayer.pause(); }
1971
1915
  seek(t) { return this.corePlayer.seek(t); }
@@ -1989,6 +1933,10 @@ class PlayerUI {
1989
1933
  window.removeEventListener('mouseup', this._seekMouseUpBound);
1990
1934
  window.removeEventListener('touchmove', this._seekTouchMoveBound);
1991
1935
  window.removeEventListener('touchend', this._seekTouchEndBound);
1936
+ document.removeEventListener('click', this._ctxDocClickBound);
1937
+ document.removeEventListener('keydown', this._ctxKeyDownBound);
1938
+ this._hideContextMenu();
1939
+ this._watermarkObserver?.disconnect();
1992
1940
  this.corePlayer.destroy();
1993
1941
  this.root.remove();
1994
1942
  }