@guardvideo/player-sdk 3.4.0 → 3.6.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
@@ -70,8 +70,19 @@ class WatchChunkAccumulator {
70
70
  }
71
71
  }
72
72
 
73
+ const VALID_EVENT_TYPES = [
74
+ 'watch_chunk',
75
+ 'play',
76
+ 'pause',
77
+ 'seek',
78
+ 'ended',
79
+ 'quality_change',
80
+ 'error',
81
+ 'security_event',
82
+ ];
73
83
  const MAX_BATCH_SIZE = 25;
74
84
  const DEFAULT_FLUSH_INTERVAL = 5000;
85
+ const MAX_FLUSH_BACKOFF = 60000;
75
86
  class EventTracker {
76
87
  constructor(config) {
77
88
  this.buffer = [];
@@ -79,6 +90,8 @@ class EventTracker {
79
90
  this.accumulator = new WatchChunkAccumulator();
80
91
  this.destroyed = false;
81
92
  this._onBeforeUnload = null;
93
+ this.flushBackoff = 0;
94
+ this.flushing = false;
82
95
  this.config = {
83
96
  endpoint: config.endpoint,
84
97
  tokenId: config.tokenId,
@@ -94,6 +107,12 @@ class EventTracker {
94
107
  track(type, positionSeconds, payload) {
95
108
  if (this.destroyed)
96
109
  return;
110
+ if (!VALID_EVENT_TYPES.includes(type)) {
111
+ if (this.config.debug) {
112
+ console.warn('[GuardVideo EventTracker] Skipping unknown event type:', type);
113
+ }
114
+ return;
115
+ }
97
116
  this.buffer.push({
98
117
  event_type: type,
99
118
  event_at: new Date().toISOString(),
@@ -117,7 +136,7 @@ class EventTracker {
117
136
  this.config.sessionId = sessionId;
118
137
  }
119
138
  async flush() {
120
- if (this.destroyed)
139
+ if (this.destroyed || this.flushing)
121
140
  return;
122
141
  const chunks = this.accumulator.drain();
123
142
  for (const c of chunks) {
@@ -125,8 +144,14 @@ class EventTracker {
125
144
  }
126
145
  if (this.buffer.length === 0)
127
146
  return;
128
- const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
129
- await this.sendBatch(batch);
147
+ this.flushing = true;
148
+ try {
149
+ const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
150
+ await this.sendBatch(batch);
151
+ }
152
+ finally {
153
+ this.flushing = false;
154
+ }
130
155
  }
131
156
  destroy() {
132
157
  this.destroyed = true;
@@ -141,6 +166,11 @@ class EventTracker {
141
166
  startAutoFlush() {
142
167
  this.flushTimer = setInterval(() => this.flush(), this.config.flushIntervalMs);
143
168
  }
169
+ restartAutoFlush(intervalMs) {
170
+ if (this.flushTimer)
171
+ clearInterval(this.flushTimer);
172
+ this.flushTimer = setInterval(() => this.flush(), intervalMs);
173
+ }
144
174
  hookPageUnload() {
145
175
  if (typeof window === 'undefined')
146
176
  return;
@@ -186,8 +216,25 @@ class EventTracker {
186
216
  body,
187
217
  credentials: 'omit',
188
218
  });
189
- if (!resp.ok && this.config.debug) {
190
- console.warn('[GuardVideo EventTracker] Flush failed:', resp.status);
219
+ if (!resp.ok) {
220
+ if (resp.status === 429) {
221
+ this.flushBackoff = Math.min((this.flushBackoff || this.config.flushIntervalMs) * 2, MAX_FLUSH_BACKOFF);
222
+ if (this.config.debug) {
223
+ console.warn('[GuardVideo EventTracker] 429 — backing off to', this.flushBackoff, 'ms');
224
+ }
225
+ this.buffer.unshift(...events);
226
+ this.restartAutoFlush(this.flushBackoff);
227
+ return;
228
+ }
229
+ if (this.config.debug) {
230
+ console.warn('[GuardVideo EventTracker] Flush failed:', resp.status);
231
+ }
232
+ }
233
+ else {
234
+ if (this.flushBackoff > 0) {
235
+ this.flushBackoff = 0;
236
+ this.restartAutoFlush(this.config.flushIntervalMs);
237
+ }
191
238
  }
192
239
  }
193
240
  catch (err) {
@@ -403,6 +450,15 @@ class GuardVideoPlayer {
403
450
  this._onSecurityEvent = null;
404
451
  this.playlistNonce = null;
405
452
  this.nonceRefreshInProgress = false;
453
+ this.nonceRefreshTimer = null;
454
+ this.retryBackoff = 1000;
455
+ this.MAX_BACKOFF = 30000;
456
+ this.rateLimitCooldownUntil = 0;
457
+ this.nonceRefreshPromise = null;
458
+ this.destroyed = false;
459
+ this.networkRetryCount = 0;
460
+ this.MAX_NETWORK_RETRIES = 6;
461
+ this.pendingRetryTimer = null;
406
462
  this._onRateChange = this.enforceMaxRate.bind(this);
407
463
  this.videoElement = videoElement;
408
464
  this.config = {
@@ -613,9 +669,18 @@ class GuardVideoPlayer {
613
669
  async solveChallenge(tokenId) {
614
670
  if (!this.config.apiBaseUrl)
615
671
  return undefined;
672
+ if (Date.now() < this.rateLimitCooldownUntil) {
673
+ this.log('Challenge skipped — rate limit cooldown active');
674
+ return undefined;
675
+ }
616
676
  try {
617
677
  const url = this.config.apiBaseUrl + '/videos/challenge/' + encodeURIComponent(tokenId);
618
678
  const resp = await fetch(url, { credentials: 'omit' });
679
+ if (resp.status === 429) {
680
+ this.enterRateLimitCooldown();
681
+ this.log('Challenge hit 429 — entering cooldown');
682
+ return undefined;
683
+ }
619
684
  if (!resp.ok) {
620
685
  this.log('Challenge fetch failed (challenge may be disabled)', resp.status);
621
686
  return undefined;
@@ -641,6 +706,10 @@ class GuardVideoPlayer {
641
706
  async acquireNonce(tokenId) {
642
707
  if (!this.config.apiBaseUrl)
643
708
  return null;
709
+ if (Date.now() < this.rateLimitCooldownUntil) {
710
+ this.log('Nonce acquisition skipped — rate limit cooldown active');
711
+ return this.playlistNonce;
712
+ }
644
713
  try {
645
714
  const challengeResult = await this.solveChallenge(tokenId);
646
715
  const url = this.config.apiBaseUrl + '/videos/playlist-session';
@@ -654,12 +723,20 @@ class GuardVideoPlayer {
654
723
  body: JSON.stringify(body),
655
724
  credentials: 'omit',
656
725
  });
726
+ if (resp.status === 429) {
727
+ this.enterRateLimitCooldown();
728
+ this.log('Nonce acquisition hit 429 — entering cooldown');
729
+ return this.playlistNonce;
730
+ }
657
731
  if (!resp.ok) {
658
732
  this.log('Playlist session nonce acquisition failed', resp.status);
659
733
  return null;
660
734
  }
661
735
  const data = await resp.json();
662
736
  this.log('Playlist nonce acquired, expires in ' + data.expiresIn + 's');
737
+ this.retryBackoff = 1000;
738
+ this.networkRetryCount = 0;
739
+ this.scheduleNonceRefresh(data.expiresIn);
663
740
  return data.nonce;
664
741
  }
665
742
  catch (err) {
@@ -667,19 +744,58 @@ class GuardVideoPlayer {
667
744
  return null;
668
745
  }
669
746
  }
747
+ scheduleNonceRefresh(expiresInSeconds) {
748
+ if (this.nonceRefreshTimer)
749
+ clearTimeout(this.nonceRefreshTimer);
750
+ const refreshMs = Math.max(5000, expiresInSeconds * 750);
751
+ this.nonceRefreshTimer = setTimeout(() => {
752
+ if (this.destroyed)
753
+ return;
754
+ this.refreshNonce().catch(() => {
755
+ this.log('Proactive nonce refresh failed');
756
+ });
757
+ }, refreshMs);
758
+ }
670
759
  async refreshNonce() {
671
- if (this.nonceRefreshInProgress)
672
- return;
760
+ if (this.nonceRefreshInProgress && this.nonceRefreshPromise) {
761
+ return this.nonceRefreshPromise;
762
+ }
673
763
  if (!this.embedToken)
674
764
  return;
675
765
  this.nonceRefreshInProgress = true;
676
- try {
677
- const nonce = await this.acquireNonce(this.embedToken.tokenId);
678
- this.playlistNonce = nonce;
679
- }
680
- finally {
681
- this.nonceRefreshInProgress = false;
766
+ this.nonceRefreshPromise = (async () => {
767
+ try {
768
+ const nonce = await this.acquireNonce(this.embedToken.tokenId);
769
+ this.playlistNonce = nonce;
770
+ }
771
+ finally {
772
+ this.nonceRefreshInProgress = false;
773
+ this.nonceRefreshPromise = null;
774
+ }
775
+ })();
776
+ return this.nonceRefreshPromise;
777
+ }
778
+ enterRateLimitCooldown() {
779
+ this.rateLimitCooldownUntil = Date.now() + this.retryBackoff;
780
+ this.retryBackoff = Math.min(this.retryBackoff * 2, this.MAX_BACKOFF);
781
+ }
782
+ scheduleRetry(fn, delayMs) {
783
+ if (this.pendingRetryTimer)
784
+ clearTimeout(this.pendingRetryTimer);
785
+ this.pendingRetryTimer = setTimeout(() => {
786
+ this.pendingRetryTimer = null;
787
+ if (!this.destroyed && this.hls)
788
+ fn();
789
+ }, delayMs);
790
+ }
791
+ stopLoading() {
792
+ if (this.pendingRetryTimer) {
793
+ clearTimeout(this.pendingRetryTimer);
794
+ this.pendingRetryTimer = null;
682
795
  }
796
+ this.hls?.stopLoad();
797
+ this.networkRetryCount = 0;
798
+ this.retryBackoff = 1000;
683
799
  }
684
800
  async initializePlayer() {
685
801
  if (!this.embedToken) {
@@ -719,8 +835,12 @@ class GuardVideoPlayer {
719
835
  enableWorker: true,
720
836
  lowLatencyMode: false,
721
837
  liveSyncDurationCount: 3,
722
- manifestLoadingMaxRetry: 6,
723
- levelLoadingMaxRetry: 6,
838
+ manifestLoadingMaxRetry: 3,
839
+ manifestLoadingRetryDelay: 2000,
840
+ levelLoadingMaxRetry: 3,
841
+ levelLoadingRetryDelay: 2000,
842
+ fragLoadingMaxRetry: 4,
843
+ fragLoadingRetryDelay: 1000,
724
844
  xhrSetup(xhr, url) {
725
845
  if (url.includes('/watermark-stream/') && url.includes('.m3u8')) {
726
846
  if (self.playlistNonce) {
@@ -728,10 +848,6 @@ class GuardVideoPlayer {
728
848
  const nonceUrl = url + separator + 'nonce=' + encodeURIComponent(self.playlistNonce);
729
849
  xhr.open('GET', nonceUrl, true);
730
850
  self.log('Injected nonce into playlist request');
731
- self.playlistNonce = null;
732
- self.refreshNonce().catch(() => {
733
- self.log('Background nonce refresh failed');
734
- });
735
851
  }
736
852
  }
737
853
  },
@@ -743,6 +859,8 @@ class GuardVideoPlayer {
743
859
  this.log('HLS manifest parsed', { levels: data.levels.map((l) => l.height + 'p') });
744
860
  this.setState(exports.PlayerState.READY);
745
861
  this.config.onReady();
862
+ this.retryBackoff = 1000;
863
+ this.networkRetryCount = 0;
746
864
  if (this.config.autoplay)
747
865
  this.play();
748
866
  });
@@ -758,28 +876,77 @@ class GuardVideoPlayer {
758
876
  this.currentQuality = quality;
759
877
  this.log('Quality switched to ' + quality.name);
760
878
  this.config.onQualityChange(quality.name);
879
+ this.eventTracker?.track('quality_change', this.videoElement.currentTime, {
880
+ level: data.level,
881
+ height: level.height,
882
+ bitrate: level.bitrate,
883
+ });
761
884
  });
762
885
  this.hls.on(Hls.Events.ERROR, (_event, data) => {
763
886
  this.error('HLS Error', data);
887
+ this.eventTracker?.track('error', this.videoElement.currentTime, {
888
+ type: data.type,
889
+ details: data.details,
890
+ fatal: data.fatal,
891
+ });
892
+ if (this.destroyed)
893
+ return;
894
+ if (Date.now() < this.rateLimitCooldownUntil) {
895
+ this.log('Suppressing retry — rate limit cooldown active (' +
896
+ Math.ceil((this.rateLimitCooldownUntil - Date.now()) / 1000) + 's remaining)');
897
+ if (data.fatal) {
898
+ const delay = this.rateLimitCooldownUntil - Date.now() + 500;
899
+ this.scheduleRetry(() => this.hls?.startLoad(), delay);
900
+ }
901
+ return;
902
+ }
903
+ const httpStatus = data.response?.code;
904
+ if (httpStatus === 429) {
905
+ this.enterRateLimitCooldown();
906
+ this.log('429 detected — entering cooldown for ' + this.retryBackoff + 'ms');
907
+ this.scheduleRetry(() => {
908
+ if (this.embedToken?.forensicWatermark) {
909
+ this.refreshNonce().then(() => this.hls?.startLoad()).catch(() => this.hls?.startLoad());
910
+ }
911
+ else {
912
+ this.hls?.startLoad();
913
+ }
914
+ }, this.retryBackoff);
915
+ return;
916
+ }
764
917
  if (data.type === Hls.ErrorTypes.NETWORK_ERROR &&
765
918
  data.details === Hls.ErrorDetails.LEVEL_LOAD_ERROR &&
766
919
  this.embedToken?.forensicWatermark) {
767
- this.log('Playlist load failed — refreshing nonce before retry');
920
+ this.networkRetryCount++;
921
+ if (this.networkRetryCount > this.MAX_NETWORK_RETRIES) {
922
+ this.error('Max network retries exceeded for playlist load');
923
+ this.handleError({ code: 'NETWORK_ERROR', message: 'Playlist load failed after multiple retries', fatal: true, details: data });
924
+ return;
925
+ }
926
+ this.log('Playlist load failed — refreshing nonce before retry (' + this.networkRetryCount + '/' + this.MAX_NETWORK_RETRIES + ')');
768
927
  this.refreshNonce().then(() => {
769
- setTimeout(() => this.hls?.startLoad(), 500);
928
+ this.scheduleRetry(() => this.hls?.startLoad(), this.retryBackoff);
770
929
  }).catch(() => {
771
- setTimeout(() => this.hls?.startLoad(), 1000);
930
+ this.scheduleRetry(() => this.hls?.startLoad(), this.retryBackoff);
772
931
  });
932
+ this.retryBackoff = Math.min(this.retryBackoff * 1.5, this.MAX_BACKOFF);
773
933
  return;
774
934
  }
775
935
  if (data.fatal) {
776
936
  switch (data.type) {
777
937
  case Hls.ErrorTypes.NETWORK_ERROR:
778
- this.error('Network error, attempting recovery...');
938
+ this.networkRetryCount++;
939
+ if (this.networkRetryCount > this.MAX_NETWORK_RETRIES) {
940
+ this.error('Max network retries exceeded — giving up');
941
+ this.handleError({ code: 'NETWORK_ERROR', message: 'Network error after multiple retries. Please check your connection.', fatal: true, details: data });
942
+ return;
943
+ }
944
+ this.error('Network error, attempting recovery (' + this.networkRetryCount + '/' + this.MAX_NETWORK_RETRIES + ')...');
779
945
  if (this.embedToken?.forensicWatermark) {
780
946
  this.refreshNonce().catch(() => { });
781
947
  }
782
- setTimeout(() => this.hls?.startLoad(), 1000);
948
+ this.scheduleRetry(() => this.hls?.startLoad(), this.retryBackoff);
949
+ this.retryBackoff = Math.min(this.retryBackoff * 1.5, this.MAX_BACKOFF);
783
950
  break;
784
951
  case Hls.ErrorTypes.MEDIA_ERROR:
785
952
  this.error('Media error, attempting recovery...');
@@ -793,8 +960,21 @@ class GuardVideoPlayer {
793
960
  this.setupVideoEventListeners();
794
961
  }
795
962
  setupVideoEventListeners() {
796
- this.videoElement.addEventListener('playing', () => this.setState(exports.PlayerState.PLAYING));
797
- this.videoElement.addEventListener('pause', () => this.setState(exports.PlayerState.PAUSED));
963
+ this.videoElement.addEventListener('playing', () => {
964
+ this.setState(exports.PlayerState.PLAYING);
965
+ if (this.hls)
966
+ this.hls.startLoad(-1);
967
+ });
968
+ this.videoElement.addEventListener('pause', () => {
969
+ this.setState(exports.PlayerState.PAUSED);
970
+ if (this.hls && !this.videoElement.seeking) {
971
+ this.hls.stopLoad();
972
+ if (this.pendingRetryTimer) {
973
+ clearTimeout(this.pendingRetryTimer);
974
+ this.pendingRetryTimer = null;
975
+ }
976
+ }
977
+ });
798
978
  this.videoElement.addEventListener('waiting', () => this.setState(exports.PlayerState.BUFFERING));
799
979
  this.videoElement.addEventListener('error', () => {
800
980
  const error = this.videoElement.error;
@@ -821,6 +1001,8 @@ class GuardVideoPlayer {
821
1001
  }
822
1002
  async play() {
823
1003
  try {
1004
+ this.networkRetryCount = 0;
1005
+ this.retryBackoff = 1000;
824
1006
  await this.videoElement.play();
825
1007
  }
826
1008
  catch (err) {
@@ -857,6 +1039,15 @@ class GuardVideoPlayer {
857
1039
  getState() { return this.state; }
858
1040
  destroy() {
859
1041
  this.log('Destroying player');
1042
+ this.destroyed = true;
1043
+ if (this.pendingRetryTimer) {
1044
+ clearTimeout(this.pendingRetryTimer);
1045
+ this.pendingRetryTimer = null;
1046
+ }
1047
+ if (this.nonceRefreshTimer) {
1048
+ clearTimeout(this.nonceRefreshTimer);
1049
+ this.nonceRefreshTimer = null;
1050
+ }
860
1051
  if (this.eventTracker) {
861
1052
  this.eventTracker.destroy();
862
1053
  this.eventTracker = null;
@@ -2567,6 +2758,7 @@ class PlayerUI {
2567
2758
  }
2568
2759
  play() { return this.corePlayer.play(); }
2569
2760
  pause() { return this.corePlayer.pause(); }
2761
+ stopLoading() { return this.corePlayer.stopLoading(); }
2570
2762
  seek(t) { return this.corePlayer.seek(t); }
2571
2763
  getCurrentTime() { return this.corePlayer.getCurrentTime(); }
2572
2764
  getDuration() { return this.corePlayer.getDuration(); }
@@ -2626,6 +2818,7 @@ const GuardVideoPlayerComponent = React.forwardRef((props, ref) => {
2626
2818
  React.useImperativeHandle(ref, () => ({
2627
2819
  play: () => uiRef.current?.play() ?? Promise.resolve(),
2628
2820
  pause: () => uiRef.current?.pause(),
2821
+ stopLoading: () => uiRef.current?.stopLoading(),
2629
2822
  seek: (t) => uiRef.current?.seek(t),
2630
2823
  getCurrentTime: () => uiRef.current?.getCurrentTime() ?? 0,
2631
2824
  getDuration: () => uiRef.current?.getDuration() ?? 0,