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