@guardvideo/player-sdk 1.2.0 → 1.3.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
@@ -1,5 +1,5 @@
1
1
  import Hls from 'hls.js';
2
- import React, { forwardRef, useRef, useState, useEffect, useCallback, useImperativeHandle } from 'react';
2
+ import React, { forwardRef, useRef, useEffect, useImperativeHandle, useState, useCallback } from 'react';
3
3
 
4
4
  var PlayerState;
5
5
  (function (PlayerState) {
@@ -657,43 +657,56 @@ class GuardVideoPlayer {
657
657
  }
658
658
  }
659
659
 
660
- function formatTime(seconds) {
661
- if (!isFinite(seconds) || isNaN(seconds))
660
+ function formatTime(s) {
661
+ if (!isFinite(s) || isNaN(s))
662
662
  return '0:00';
663
- const h = Math.floor(seconds / 3600);
664
- const m = Math.floor((seconds % 3600) / 60);
665
- const s = Math.floor(seconds % 60);
663
+ const h = Math.floor(s / 3600);
664
+ const m = Math.floor((s % 3600) / 60);
665
+ const sc = Math.floor(s % 60);
666
666
  const mm = String(m).padStart(h > 0 ? 2 : 1, '0');
667
- const ss = String(s).padStart(2, '0');
667
+ const ss = String(sc).padStart(2, '0');
668
668
  return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
669
669
  }
670
- const PlayIcon = () => (React.createElement("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor" },
671
- React.createElement("polygon", { points: "5,3 19,12 5,21" })));
672
- const PauseIcon = () => (React.createElement("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor" },
673
- React.createElement("rect", { x: "6", y: "4", width: "4", height: "16", rx: "1" }),
674
- React.createElement("rect", { x: "14", y: "4", width: "4", height: "16", rx: "1" })));
675
- const ReplayIcon = () => (React.createElement("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor" },
676
- React.createElement("path", { d: "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" })));
677
- const VolumeHighIcon = () => (React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor" },
678
- React.createElement("path", { d: "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" })));
679
- const VolumeMuteIcon = () => (React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor" },
680
- React.createElement("path", { d: "M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" })));
681
- const FullscreenIcon = () => (React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor" },
682
- React.createElement("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" })));
683
- const ExitFullscreenIcon = () => (React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor" },
684
- React.createElement("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" })));
685
- const SettingsIcon = () => (React.createElement("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor" },
686
- React.createElement("path", { d: "M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z" })));
687
- const ShieldIcon = ({ color }) => (React.createElement("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: color, strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" },
688
- React.createElement("path", { d: "M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" })));
670
+ function el(tag, cls, attrs) {
671
+ const e = document.createElement(tag);
672
+ if (cls)
673
+ e.className = cls;
674
+ if (attrs)
675
+ for (const [k, v] of Object.entries(attrs))
676
+ e.setAttribute(k, v);
677
+ return e;
678
+ }
679
+ function svgEl(path, w = 20, h = 20) {
680
+ const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
681
+ s.setAttribute('width', String(w));
682
+ s.setAttribute('height', String(h));
683
+ s.setAttribute('viewBox', '0 0 24 24');
684
+ s.setAttribute('fill', 'currentColor');
685
+ s.setAttribute('aria-hidden', 'true');
686
+ s.innerHTML = path;
687
+ return s;
688
+ }
689
+ const ICON = {
690
+ play: '<polygon points="5,3 19,12 5,21"/>',
691
+ pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
692
+ replay: '<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>',
693
+ volHigh: '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>',
694
+ volMute: '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>',
695
+ fsEnter: '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>',
696
+ fsExit: '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>',
697
+ settings: '<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94zM12,15.6c-1.98,0-3.6-1.62-3.6-3.6s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>',
698
+ shield: '<path fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
699
+ };
700
+ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
689
701
  let _stylesInjected = false;
690
- function injectPlayerStyles(accentColor) {
702
+ function injectStyles() {
691
703
  if (_stylesInjected)
692
704
  return;
693
705
  _stylesInjected = true;
694
706
  const css = `
695
- /* ── GuardVideo Custom Player ─────────────────────────────── */
707
+ /* ── GuardVideo Player UI ─────────────────────────────────── */
696
708
  .gvp-root {
709
+ --gvp-accent: #44c09b;
697
710
  position: relative;
698
711
  background: #000;
699
712
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@@ -703,717 +716,832 @@ function injectPlayerStyles(accentColor) {
703
716
  user-select: none;
704
717
  outline: none;
705
718
  }
706
- .gvp-root:focus-visible { box-shadow: 0 0 0 3px ${accentColor}66; }
719
+ .gvp-root:focus-visible { outline: 2px solid var(--gvp-accent); outline-offset: 1px; }
707
720
 
708
- .gvp-video {
709
- display: block;
710
- width: 100%;
711
- height: 100%;
712
- object-fit: contain;
713
- border-radius: 10px;
714
- }
721
+ .gvp-video { display: block; width: 100%; height: 100%; object-fit: contain; }
722
+
723
+ /* ── Utility: hide elements ────────────────────────────────── */
724
+ .gvp-hidden { display: none !important; }
725
+ /* Controls use opacity/translate so they animate out gracefully */
726
+ .gvp-controls-hidden { opacity: 0; transform: translateY(6px); pointer-events: none; }
715
727
 
716
- /* ── Loading spinner ─────────────────────────────────────── */
728
+ /* ── Spinner ─────────────────────────────────────────────── */
717
729
  .gvp-spinner {
718
- position: absolute;
719
- inset: 0;
720
- display: flex;
721
- align-items: center;
722
- justify-content: center;
723
- pointer-events: none;
724
- background: rgba(0,0,0,0.55);
725
- border-radius: 10px;
730
+ position: absolute; inset: 0; display: flex;
731
+ align-items: center; justify-content: center;
732
+ pointer-events: none; background: rgba(0,0,0,.55); border-radius: 10px;
726
733
  }
727
734
  .gvp-spinner-ring {
728
- width: 48px;
729
- height: 48px;
730
- border: 3px solid rgba(255,255,255,0.15);
731
- border-top-color: ${accentColor};
732
- border-radius: 50%;
733
- animation: gvp-spin 0.8s linear infinite;
735
+ width: 48px; height: 48px;
736
+ border: 3px solid rgba(255,255,255,.15);
737
+ border-top-color: var(--gvp-accent);
738
+ border-radius: 50%; animation: gvp-spin .8s linear infinite;
734
739
  }
735
740
  @keyframes gvp-spin { to { transform: rotate(360deg); } }
736
741
 
737
- /* ── Big centre play button (initial state) ───────────────── */
742
+ /* ── Centre play button ────────────────────────────────────── */
738
743
  .gvp-center-play {
739
- position: absolute;
740
- inset: 0;
741
- display: flex;
742
- align-items: center;
743
- justify-content: center;
744
- cursor: pointer;
745
- background: rgba(0,0,0,0.3);
746
- transition: background 0.2s;
747
- border-radius: 10px;
744
+ position: absolute; inset: 0; z-index: 3;
745
+ display: flex; align-items: center; justify-content: center;
746
+ cursor: pointer; background: rgba(0,0,0,.3);
747
+ transition: background .2s; border-radius: 10px;
748
748
  }
749
- .gvp-center-play:hover { background: rgba(0,0,0,0.45); }
749
+ .gvp-center-play:hover { background: rgba(0,0,0,.45); }
750
750
  .gvp-center-play-btn {
751
- width: 72px;
752
- height: 72px;
753
- background: rgba(255,255,255,0.12);
754
- backdrop-filter: blur(8px);
755
- border-radius: 50%;
756
- display: flex;
757
- align-items: center;
758
- justify-content: center;
759
- border: 2px solid rgba(255,255,255,0.25);
760
- transition: background 0.2s, transform 0.15s;
761
- color: #fff;
762
- }
763
- .gvp-center-play:hover .gvp-center-play-btn {
764
- background: ${accentColor}cc;
765
- transform: scale(1.08);
751
+ width: 72px; height: 72px;
752
+ background: rgba(255,255,255,.12); backdrop-filter: blur(8px);
753
+ border-radius: 50%; display: flex; align-items: center; justify-content: center;
754
+ border: 2px solid rgba(255,255,255,.25);
755
+ transition: background .2s, transform .15s; color: #fff;
766
756
  }
757
+ .gvp-center-play:hover .gvp-center-play-btn { background: var(--gvp-accent); transform: scale(1.08); }
767
758
 
768
- /* ── Click-to-toggle overlay ─────────────────────────────── */
769
- .gvp-click-area {
770
- position: absolute;
771
- inset: 0;
772
- cursor: pointer;
773
- z-index: 1;
774
- }
759
+ /* ── Click-to-toggle overlay ───────────────────────────────── */
760
+ .gvp-click-area { position: absolute; inset: 0; cursor: pointer; z-index: 1; }
775
761
 
776
- /* ── Ripple effect on click ──────────────────────────────── */
762
+ /* ── Ripple ───────────────────────────────────────────────── */
777
763
  .gvp-ripple {
778
- position: absolute;
779
- border-radius: 50%;
780
- transform: scale(0);
781
- background: rgba(255,255,255,0.25);
782
- animation: gvp-ripple-anim 0.5s ease-out forwards;
783
- pointer-events: none;
784
- z-index: 2;
764
+ position: absolute; border-radius: 50%; transform: scale(0);
765
+ background: rgba(255,255,255,.25);
766
+ animation: gvp-ripple-anim .5s ease-out forwards;
767
+ pointer-events: none; z-index: 2;
785
768
  }
786
769
  @keyframes gvp-ripple-anim { to { transform: scale(4); opacity: 0; } }
787
770
 
788
- /* ── Controls bar ────────────────────────────────────────── */
771
+ /* ── Controls bar ──────────────────────────────────────────── */
789
772
  .gvp-controls {
790
- position: absolute;
791
- bottom: 0;
792
- left: 0;
793
- right: 0;
794
- z-index: 10;
795
- background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%);
773
+ position: absolute; bottom: 0; left: 0; right: 0; z-index: 10;
774
+ background: linear-gradient(to top, rgba(0,0,0,.85) 0%, transparent 100%);
796
775
  padding: 36px 14px 10px;
797
- transition: opacity 0.3s, transform 0.3s;
776
+ transition: opacity .3s, transform .3s;
798
777
  border-radius: 0 0 10px 10px;
799
778
  }
800
- .gvp-controls-hidden {
801
- opacity: 0;
802
- transform: translateY(6px);
803
- pointer-events: none;
804
- }
805
779
 
806
- /* ── Progress / seek bar ─────────────────────────────────── */
807
- .gvp-seek-row {
808
- display: flex;
809
- align-items: center;
810
- gap: 8px;
811
- margin-bottom: 6px;
812
- }
780
+ /* ── Seek bar ──────────────────────────────────────────────── */
781
+ .gvp-seek-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
813
782
  .gvp-seek-wrap {
814
- flex: 1;
815
- position: relative;
816
- height: 4px;
817
- cursor: pointer;
818
- padding: 8px 0;
819
- margin: -8px 0;
820
- box-sizing: content-box;
783
+ flex: 1; position: relative; height: 4px; cursor: pointer;
784
+ padding: 8px 0; margin: -8px 0; box-sizing: content-box;
785
+ /* Prevent page scroll while touch-seeking */
786
+ touch-action: none;
821
787
  }
822
788
  .gvp-seek-track {
823
- height: 4px;
824
- background: rgba(255,255,255,0.2);
825
- border-radius: 4px;
826
- position: relative;
827
- overflow: hidden;
828
- transition: height 0.15s;
789
+ height: 4px; background: rgba(255,255,255,.2); border-radius: 4px;
790
+ position: relative; overflow: hidden; transition: height .15s;
791
+ pointer-events: none;
829
792
  }
830
- .gvp-seek-wrap:hover .gvp-seek-track { height: 6px; }
793
+ .gvp-seek-wrap:hover .gvp-seek-track,
794
+ .gvp-seek-wrap.gvp-dragging .gvp-seek-track { height: 6px; }
795
+
831
796
  .gvp-seek-buffered {
832
- position: absolute;
833
- left: 0;
834
- top: 0;
835
- height: 100%;
836
- background: rgba(255,255,255,0.35);
837
- border-radius: 4px;
838
- pointer-events: none;
797
+ position: absolute; left: 0; top: 0; height: 100%;
798
+ background: rgba(255,255,255,.35); border-radius: 4px; pointer-events: none;
839
799
  }
840
800
  .gvp-seek-progress {
841
- position: absolute;
842
- left: 0;
843
- top: 0;
844
- height: 100%;
845
- background: ${accentColor};
846
- border-radius: 4px;
847
- pointer-events: none;
848
- transition: width 0.1s linear;
801
+ position: absolute; left: 0; top: 0; height: 100%;
802
+ background: var(--gvp-accent); border-radius: 4px; pointer-events: none;
849
803
  }
850
804
  .gvp-seek-thumb {
851
- position: absolute;
852
- top: 50%;
853
- transform: translate(-50%, -50%);
854
- width: 13px;
855
- height: 13px;
856
- background: #fff;
857
- border-radius: 50%;
858
- box-shadow: 0 1px 4px rgba(0,0,0,0.5);
859
- pointer-events: none;
860
- opacity: 0;
861
- transition: opacity 0.15s;
805
+ position: absolute; top: 50%; transform: translate(-50%,-50%);
806
+ width: 13px; height: 13px; background: #fff; border-radius: 50%;
807
+ box-shadow: 0 1px 4px rgba(0,0,0,.5); pointer-events: none;
808
+ opacity: 0; transition: opacity .15s;
862
809
  }
863
- .gvp-seek-wrap:hover .gvp-seek-thumb { opacity: 1; }
810
+ .gvp-seek-wrap:hover .gvp-seek-thumb,
811
+ .gvp-seek-wrap.gvp-dragging .gvp-seek-thumb { opacity: 1; }
864
812
 
865
- /* ── Time tooltip ─────────────────────────────────────────── */
866
813
  .gvp-seek-tooltip {
867
- position: absolute;
868
- bottom: 24px;
869
- transform: translateX(-50%);
870
- background: rgba(0,0,0,0.8);
871
- color: #fff;
872
- font-size: 11px;
873
- font-weight: 500;
874
- padding: 3px 7px;
875
- border-radius: 4px;
876
- pointer-events: none;
877
- white-space: nowrap;
878
- opacity: 0;
879
- transition: opacity 0.1s;
814
+ position: absolute; bottom: 24px; transform: translateX(-50%);
815
+ background: rgba(0,0,0,.8); color: #fff; font-size: 11px; font-weight: 500;
816
+ padding: 3px 7px; border-radius: 4px;
817
+ pointer-events: none; white-space: nowrap;
818
+ opacity: 0; transition: opacity .1s;
880
819
  }
881
820
  .gvp-seek-wrap:hover .gvp-seek-tooltip { opacity: 1; }
882
821
 
883
- /* ── Bottom control row ───────────────────────────────────── */
884
- .gvp-btn-row {
885
- display: flex;
886
- align-items: center;
887
- gap: 4px;
888
- }
822
+ /* ── Button row ────────────────────────────────────────────── */
823
+ .gvp-btn-row { display: flex; align-items: center; gap: 4px; }
889
824
  .gvp-btn {
890
- background: none;
891
- border: none;
892
- color: #fff;
893
- cursor: pointer;
894
- padding: 5px;
895
- border-radius: 6px;
896
- display: flex;
897
- align-items: center;
898
- justify-content: center;
899
- transition: background 0.15s, color 0.15s;
900
- flex-shrink: 0;
825
+ background: none; border: none; color: #fff; cursor: pointer;
826
+ padding: 5px; border-radius: 6px;
827
+ display: flex; align-items: center; justify-content: center;
828
+ transition: background .15s, color .15s; flex-shrink: 0;
901
829
  }
902
- .gvp-btn:hover { background: rgba(255,255,255,0.12); color: ${accentColor}; }
903
- .gvp-btn:active { background: rgba(255,255,255,0.2); }
830
+ .gvp-btn:hover { background: rgba(255,255,255,.12); color: var(--gvp-accent); }
831
+ .gvp-btn:active { background: rgba(255,255,255,.2); }
904
832
 
905
- /* ── Volume section ──────────────────────────────────────── */
906
- .gvp-volume-wrap {
907
- display: flex;
908
- align-items: center;
909
- gap: 5px;
910
- }
833
+ /* ── Volume ────────────────────────────────────────────────── */
834
+ .gvp-volume-wrap { display: flex; align-items: center; gap: 5px; }
911
835
  .gvp-volume-slider {
912
- -webkit-appearance: none;
913
- width: 70px;
914
- height: 4px;
915
- background: rgba(255,255,255,0.25);
916
- border-radius: 4px;
917
- outline: none;
918
- cursor: pointer;
919
- accent-color: ${accentColor};
920
- transition: width 0.2s;
836
+ -webkit-appearance: none; width: 70px; height: 4px;
837
+ background: rgba(255,255,255,.25); border-radius: 4px;
838
+ outline: none; cursor: pointer; accent-color: var(--gvp-accent);
921
839
  }
922
840
  .gvp-volume-slider::-webkit-slider-thumb {
923
- -webkit-appearance: none;
924
- width: 12px; height: 12px;
925
- border-radius: 50%;
926
- background: #fff;
927
- cursor: pointer;
928
- box-shadow: 0 1px 3px rgba(0,0,0,0.4);
841
+ -webkit-appearance: none; width: 12px; height: 12px;
842
+ border-radius: 50%; background: #fff; cursor: pointer;
843
+ box-shadow: 0 1px 3px rgba(0,0,0,.4);
929
844
  }
930
845
  .gvp-volume-slider::-moz-range-thumb {
931
- width: 12px; height: 12px;
932
- border-radius: 50%;
933
- background: #fff;
934
- cursor: pointer;
935
- border: none;
846
+ width: 12px; height: 12px; border-radius: 50%;
847
+ background: #fff; cursor: pointer; border: none;
936
848
  }
937
849
 
938
- /* ── Time display ─────────────────────────────────────────── */
850
+ /* ── Time display ──────────────────────────────────────────── */
939
851
  .gvp-time {
940
- font-size: 12px;
941
- color: rgba(255,255,255,0.85);
942
- font-variant-numeric: tabular-nums;
943
- white-space: nowrap;
944
- letter-spacing: 0.02em;
852
+ font-size: 12px; color: rgba(255,255,255,.85);
853
+ font-variant-numeric: tabular-nums; white-space: nowrap; letter-spacing: .02em;
945
854
  }
946
-
947
- /* ── Spacer ──────────────────────────────────────────────── */
948
855
  .gvp-spacer { flex: 1; }
949
856
 
950
- /* ── Quality / Settings menu ──────────────────────────────── */
951
- .gvp-menu-wrap {
952
- position: relative;
953
- }
857
+ /* ── Popup menus ───────────────────────────────────────────── */
858
+ .gvp-menu-wrap { position: relative; }
954
859
  .gvp-menu {
955
- position: absolute;
956
- bottom: calc(100% + 8px);
957
- right: 0;
958
- background: rgba(18,18,22,0.95);
959
- backdrop-filter: blur(12px);
960
- -webkit-backdrop-filter: blur(12px);
961
- border: 1px solid rgba(255,255,255,0.08);
962
- border-radius: 8px;
963
- min-width: 140px;
964
- overflow: hidden;
965
- box-shadow: 0 8px 24px rgba(0,0,0,0.5);
966
- animation: gvp-menu-in 0.1s ease-out;
967
- z-index: 20;
860
+ position: absolute; bottom: calc(100% + 8px); right: 0;
861
+ background: rgba(18,18,22,.95);
862
+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
863
+ border: 1px solid rgba(255,255,255,.08); border-radius: 8px;
864
+ min-width: 140px; overflow: hidden;
865
+ box-shadow: 0 8px 24px rgba(0,0,0,.5);
866
+ animation: gvp-menu-in .1s ease-out; z-index: 20;
867
+ }
868
+ @keyframes gvp-menu-in {
869
+ from { opacity: 0; transform: scale(.95) translateY(4px); }
870
+ to { opacity: 1; transform: none; }
968
871
  }
969
- @keyframes gvp-menu-in { from { opacity:0; transform: scale(0.95) translateY(4px); } to { opacity:1; transform: scale(1) translateY(0); } }
970
872
  .gvp-menu-title {
971
- font-size: 10px;
972
- font-weight: 600;
973
- text-transform: uppercase;
974
- letter-spacing: 0.08em;
975
- color: rgba(255,255,255,0.4);
976
- padding: 8px 12px 4px;
873
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
874
+ letter-spacing: .08em; color: rgba(255,255,255,.4); padding: 8px 12px 4px;
977
875
  }
978
876
  .gvp-menu-item {
979
- display: flex;
980
- align-items: center;
981
- justify-content: space-between;
982
- padding: 7px 12px;
983
- font-size: 13px;
984
- color: rgba(255,255,255,0.85);
985
- cursor: pointer;
986
- transition: background 0.12s;
987
- gap: 10px;
877
+ display: flex; align-items: center; justify-content: space-between;
878
+ padding: 7px 12px; font-size: 13px; color: rgba(255,255,255,.85);
879
+ cursor: pointer; transition: background .12s; gap: 10px;
988
880
  }
989
- .gvp-menu-item:hover { background: rgba(255,255,255,0.07); }
990
- .gvp-menu-item-active { color: ${accentColor}; font-weight: 600; }
991
- .gvp-menu-check { font-size: 14px; color: ${accentColor}; }
992
- .gvp-menu-sep { height: 1px; background: rgba(255,255,255,0.07); margin: 3px 0; }
881
+ .gvp-menu-item:hover { background: rgba(255,255,255,.07); }
882
+ .gvp-menu-item-active { color: var(--gvp-accent); font-weight: 600; }
883
+ .gvp-menu-check { font-size: 14px; color: var(--gvp-accent); }
884
+ .gvp-menu-sep { height: 1px; background: rgba(255,255,255,.07); margin: 3px 0; }
993
885
 
994
- /* ── Error overlay ────────────────────────────────────────── */
886
+ /* ── Error overlay ─────────────────────────────────────────── */
995
887
  .gvp-error {
996
- position: absolute;
997
- inset: 0;
998
- display: flex;
999
- flex-direction: column;
1000
- align-items: center;
1001
- justify-content: center;
1002
- background: rgba(0,0,0,0.8);
1003
- color: #fff;
1004
- gap: 10px;
1005
- padding: 24px;
1006
- border-radius: 10px;
1007
- text-align: center;
888
+ position: absolute; inset: 0; z-index: 15;
889
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
890
+ background: rgba(0,0,0,.8); color: #fff; gap: 10px; padding: 24px;
891
+ border-radius: 10px; text-align: center;
1008
892
  }
1009
893
  .gvp-error-icon { font-size: 36px; }
1010
- .gvp-error-code { font-size: 11px; font-weight: 700; letter-spacing: 0.1em; color: #f87171; text-transform: uppercase; }
1011
- .gvp-error-msg { font-size: 14px; color: rgba(255,255,255,0.7); max-width: 320px; line-height: 1.5; }
894
+ .gvp-error-code { font-size: 11px; font-weight: 700; letter-spacing: .1em; color: #f87171; text-transform: uppercase; }
895
+ .gvp-error-msg { font-size: 14px; color: rgba(255,255,255,.7); max-width: 320px; line-height: 1.5; }
1012
896
 
1013
- /* ── Secure badge ─────────────────────────────────────────── */
897
+ /* ── Secure badge ──────────────────────────────────────────── */
1014
898
  .gvp-badge {
1015
- position: absolute;
1016
- top: 10px;
1017
- right: 12px;
1018
- display: flex;
1019
- align-items: center;
1020
- gap: 5px;
1021
- background: rgba(0,0,0,0.55);
1022
- backdrop-filter: blur(6px);
1023
- border-radius: 20px;
1024
- padding: 3px 9px 3px 7px;
1025
- font-size: 10px;
1026
- font-weight: 600;
1027
- color: ${accentColor};
1028
- pointer-events: none;
1029
- z-index: 5;
1030
- letter-spacing: 0.04em;
1031
- transition: opacity 0.3s;
899
+ position: absolute; top: 10px; right: 12px; z-index: 5;
900
+ display: flex; align-items: center; gap: 5px;
901
+ background: rgba(0,0,0,.55); backdrop-filter: blur(6px);
902
+ border-radius: 20px; padding: 3px 9px 3px 7px;
903
+ font-size: 10px; font-weight: 600; color: var(--gvp-accent);
904
+ pointer-events: none; letter-spacing: .04em; transition: opacity .3s;
1032
905
  }
1033
906
  .gvp-badge-hidden { opacity: 0; }
1034
907
 
1035
- /* ── Forensic watermark strip ─────────────────────────────── */
1036
- .gvp-watermark {
1037
- position: absolute;
1038
- inset: 0;
1039
- pointer-events: none;
1040
- overflow: hidden;
1041
- z-index: 6;
1042
- }
908
+ /* ── Forensic watermark ────────────────────────────────────── */
909
+ .gvp-watermark { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 6; }
1043
910
  .gvp-watermark-text {
1044
- position: absolute;
1045
- white-space: nowrap;
1046
- font-size: 13px;
1047
- font-family: monospace;
1048
- color: rgba(255,255,255,0.065);
1049
- transform: rotate(-28deg);
1050
- user-select: none;
1051
- pointer-events: none;
1052
- letter-spacing: 0.05em;
911
+ position: absolute; white-space: nowrap; font-size: 13px; font-family: monospace;
912
+ color: rgba(255,255,255,.065); transform: rotate(-28deg);
913
+ user-select: none; pointer-events: none; letter-spacing: .05em;
1053
914
  }
1054
915
 
1055
- /* ── Playback rate menu ────────────────────────────────────── */
916
+ /* ── Speed label button ────────────────────────────────────── */
1056
917
  .gvp-rate-label {
1057
- font-size: 11px;
1058
- font-weight: 700;
1059
- color: rgba(255,255,255,0.7);
1060
- min-width: 28px;
1061
- text-align: center;
1062
- cursor: pointer;
1063
- padding: 5px 4px;
1064
- border-radius: 4px;
1065
- transition: background 0.12s;
1066
- flex-shrink: 0;
918
+ font-size: 11px; font-weight: 700; color: rgba(255,255,255,.7);
919
+ min-width: 28px; text-align: center; cursor: pointer;
920
+ padding: 5px 4px; border-radius: 4px; transition: background .12s; flex-shrink: 0;
1067
921
  }
1068
- .gvp-rate-label:hover { background: rgba(255,255,255,0.1); }
922
+ .gvp-rate-label:hover { background: rgba(255,255,255,.1); }
1069
923
  `;
1070
924
  const tag = document.createElement('style');
1071
925
  tag.setAttribute('data-guardvideo', 'player-ui-styles');
1072
926
  tag.textContent = css;
1073
927
  document.head.appendChild(tag);
1074
928
  }
1075
- const GuardVideoPlayerComponent = forwardRef((props, ref) => {
1076
- const { videoId, width = '100%', height = 'auto', embedTokenEndpoint, apiBaseUrl, debug = false, autoplay = false, className = '', style = {}, hlsConfig, branding, contextMenuItems, security, viewerName, viewerEmail, forensicWatermark = true, onReady, onError, onQualityChange, onStateChange, onTimeUpdate: onTimeUpdateProp, onEnded, } = props;
1077
- const accentColor = branding?.accentColor ?? '#44c09b';
1078
- const brandName = branding?.name ?? 'GuardVideo';
1079
- const containerRef = useRef(null);
1080
- const videoRef = useRef(null);
1081
- const playerRef = useRef(null);
1082
- const seekRef = useRef(null);
1083
- const hideTimer = useRef(null);
1084
- const [playerState, setPlayerState] = useState(PlayerState.IDLE);
1085
- const [currentTime, setCurrentTime] = useState(0);
1086
- const [duration, setDuration] = useState(0);
1087
- const [buffered, setBuffered] = useState(0);
1088
- const [volume, setVolume] = useState(1);
1089
- const [muted, setMuted] = useState(false);
1090
- const [showControls, setShowControls] = useState(true);
1091
- const [isFullscreen, setIsFullscreen] = useState(false);
1092
- const [showMenu, setShowMenu] = useState(null);
1093
- const [qualityLevels, setQualityLevels] = useState([]);
1094
- const [currentQualityIdx, setCurrentQualityIdx] = useState(-1);
1095
- const [playbackRate, setPlaybackRate] = useState(1);
1096
- const [seekTooltip, setSeekTooltip] = useState(null);
1097
- const [error, setError] = useState(null);
1098
- const [watermarkText, setWatermarkText] = useState(null);
1099
- useEffect(() => { injectPlayerStyles(accentColor); }, [accentColor]);
1100
- useEffect(() => {
1101
- if (!videoRef.current || playerRef.current)
1102
- return;
1103
- const player = new GuardVideoPlayer(videoRef.current, videoId, {
1104
- embedTokenEndpoint,
1105
- apiBaseUrl,
1106
- debug,
1107
- autoplay,
929
+ class PlayerUI {
930
+ constructor(container, videoId, config) {
931
+ this.playerState = PlayerState.IDLE;
932
+ this.duration = 0;
933
+ this.bufferedEnd = 0;
934
+ this.qualityLevels = [];
935
+ this.currentQualityIdx = -1;
936
+ this.playbackRate = 1;
937
+ this.openMenu = null;
938
+ this.hideTimer = null;
939
+ this.seekDragging = false;
940
+ const accent = config.branding?.accentColor ?? '#44c09b';
941
+ const brandName = config.branding?.name ?? 'GuardVideo';
942
+ injectStyles();
943
+ container.innerHTML = '';
944
+ this.root = el('div', 'gvp-root');
945
+ this.root.style.width = config.width ?? '100%';
946
+ this.root.style.height = config.height ?? 'auto';
947
+ this.root.style.setProperty('--gvp-accent', accent);
948
+ this.root.setAttribute('tabindex', '0');
949
+ this.root.setAttribute('role', 'region');
950
+ this.root.setAttribute('aria-label', `${brandName} video player`);
951
+ this.videoEl = el('video', 'gvp-video', { playsinline: '', preload: 'metadata' });
952
+ this.badge = el('div', 'gvp-badge');
953
+ this.badge.setAttribute('aria-hidden', 'true');
954
+ this.badge.appendChild(svgEl(ICON.shield, 14, 14));
955
+ this.badge.appendChild(document.createTextNode(brandName));
956
+ this.watermarkDiv = el('div', 'gvp-watermark');
957
+ this.watermarkDiv.setAttribute('aria-hidden', 'true');
958
+ this.spinner = el('div', 'gvp-spinner gvp-hidden');
959
+ this.spinner.setAttribute('aria-label', 'Loading');
960
+ this.spinner.setAttribute('role', 'status');
961
+ this.spinner.appendChild(el('div', 'gvp-spinner-ring'));
962
+ this.errorOverlay = el('div', 'gvp-error gvp-hidden');
963
+ this.errorOverlay.setAttribute('role', 'alert');
964
+ const errIcon = el('div', 'gvp-error-icon');
965
+ errIcon.textContent = '⚠️';
966
+ errIcon.setAttribute('aria-hidden', 'true');
967
+ const errCode = el('div', 'gvp-error-code');
968
+ const errMsg = el('div', 'gvp-error-msg');
969
+ this.errorOverlay.append(errIcon, errCode, errMsg);
970
+ this.centerPlay = el('div', 'gvp-center-play');
971
+ this.centerPlay.setAttribute('role', 'button');
972
+ this.centerPlay.setAttribute('aria-label', 'Play');
973
+ this.centerPlayBtn = el('div', 'gvp-center-play-btn');
974
+ this.centerPlayBtn.appendChild(svgEl(ICON.play, 32, 32));
975
+ this.centerPlay.appendChild(this.centerPlayBtn);
976
+ this.clickArea = el('div', 'gvp-click-area gvp-hidden');
977
+ this.clickArea.setAttribute('aria-hidden', 'true');
978
+ this.controls = el('div', 'gvp-controls');
979
+ const seekRow = el('div', 'gvp-seek-row');
980
+ this.seekWrap = el('div', 'gvp-seek-wrap');
981
+ this.seekWrap.setAttribute('role', 'slider');
982
+ this.seekWrap.setAttribute('aria-label', 'Seek');
983
+ this.seekWrap.setAttribute('aria-valuemin', '0');
984
+ this.seekWrap.setAttribute('aria-valuemax', '100');
985
+ this.seekWrap.setAttribute('aria-valuenow', '0');
986
+ this.seekWrap.setAttribute('aria-valuetext', '0:00 of 0:00');
987
+ this.seekWrap.setAttribute('tabindex', '0');
988
+ const seekTrack = el('div', 'gvp-seek-track');
989
+ this.seekBuffered = el('div', 'gvp-seek-buffered');
990
+ this.seekProgress = el('div', 'gvp-seek-progress');
991
+ this.seekThumb = el('div', 'gvp-seek-thumb');
992
+ this.seekTooltip = el('div', 'gvp-seek-tooltip');
993
+ this.seekTooltip.setAttribute('aria-hidden', 'true');
994
+ seekTrack.append(this.seekBuffered, this.seekProgress);
995
+ this.seekWrap.append(seekTrack, this.seekThumb, this.seekTooltip);
996
+ seekRow.appendChild(this.seekWrap);
997
+ this.controls.appendChild(seekRow);
998
+ const btnRow = el('div', 'gvp-btn-row');
999
+ this.playBtn = el('button', 'gvp-btn');
1000
+ this.playBtn.type = 'button';
1001
+ this.playBtn.setAttribute('aria-label', 'Play');
1002
+ const volWrap = el('div', 'gvp-volume-wrap');
1003
+ this.volBtn = el('button', 'gvp-btn');
1004
+ this.volBtn.type = 'button';
1005
+ this.volBtn.setAttribute('aria-label', 'Mute');
1006
+ this.volBtn.appendChild(svgEl(ICON.volHigh, 18, 18));
1007
+ this.volSlider = el('input', 'gvp-volume-slider');
1008
+ Object.assign(this.volSlider, { type: 'range', min: '0', max: '1', step: '0.02', value: '1' });
1009
+ this.volSlider.setAttribute('aria-label', 'Volume');
1010
+ volWrap.append(this.volBtn, this.volSlider);
1011
+ this.timeEl = el('span', 'gvp-time');
1012
+ this.timeEl.setAttribute('aria-live', 'off');
1013
+ this.timeEl.textContent = '0:00 / 0:00';
1014
+ const spacer = el('div', 'gvp-spacer');
1015
+ const speedWrap = el('div', 'gvp-menu-wrap');
1016
+ this.speedLabel = el('span', 'gvp-rate-label');
1017
+ this.speedLabel.textContent = '1×';
1018
+ this.speedLabel.setAttribute('role', 'button');
1019
+ this.speedLabel.setAttribute('aria-label', 'Playback speed');
1020
+ this.speedLabel.setAttribute('aria-haspopup', 'menu');
1021
+ this.speedMenu = el('div', 'gvp-menu gvp-hidden');
1022
+ this.speedMenu.setAttribute('role', 'menu');
1023
+ this.speedMenu.setAttribute('aria-label', 'Playback speed');
1024
+ const speedTitle = el('div', 'gvp-menu-title');
1025
+ speedTitle.setAttribute('aria-hidden', 'true');
1026
+ speedTitle.textContent = 'Speed';
1027
+ this.speedMenu.appendChild(speedTitle);
1028
+ SPEEDS.forEach(r => {
1029
+ const item = el('div', r === 1 ? 'gvp-menu-item gvp-menu-item-active' : 'gvp-menu-item');
1030
+ item.setAttribute('role', 'menuitemradio');
1031
+ item.setAttribute('aria-checked', r === 1 ? 'true' : 'false');
1032
+ item.dataset['speed'] = String(r);
1033
+ item.textContent = r === 1 ? 'Normal' : `${r}×`;
1034
+ if (r === 1) {
1035
+ const check = el('span', 'gvp-menu-check');
1036
+ check.setAttribute('aria-hidden', 'true');
1037
+ check.textContent = '✓';
1038
+ item.appendChild(check);
1039
+ }
1040
+ this.speedMenu.appendChild(item);
1041
+ });
1042
+ speedWrap.append(this.speedLabel, this.speedMenu);
1043
+ const qualWrap = el('div', 'gvp-menu-wrap');
1044
+ this.settingsBtn = el('button', 'gvp-btn');
1045
+ this.settingsBtn.type = 'button';
1046
+ this.settingsBtn.setAttribute('aria-label', 'Quality settings');
1047
+ this.settingsBtn.setAttribute('aria-haspopup', 'menu');
1048
+ this.settingsBtn.appendChild(svgEl(ICON.settings, 18, 18));
1049
+ this.settingsBtn.classList.add('gvp-hidden');
1050
+ this.qualityMenu = el('div', 'gvp-menu gvp-hidden');
1051
+ this.qualityMenu.setAttribute('role', 'menu');
1052
+ this.qualityMenu.setAttribute('aria-label', 'Video quality');
1053
+ qualWrap.append(this.settingsBtn, this.qualityMenu);
1054
+ this.fsBtn = el('button', 'gvp-btn');
1055
+ this.fsBtn.type = 'button';
1056
+ this.fsBtn.setAttribute('aria-label', 'Enter fullscreen');
1057
+ this.fsBtn.appendChild(svgEl(ICON.fsEnter, 18, 18));
1058
+ this.playBtn.appendChild(svgEl(ICON.play));
1059
+ btnRow.append(this.playBtn, volWrap, this.timeEl, spacer, speedWrap, qualWrap, this.fsBtn);
1060
+ this.controls.appendChild(btnRow);
1061
+ this.root.append(this.videoEl, this.badge, this.watermarkDiv, this.spinner, this.errorOverlay, this.centerPlay, this.clickArea, this.controls);
1062
+ container.appendChild(this.root);
1063
+ this._onFsChangeBound = () => this._onFsChange();
1064
+ this._seekMouseMoveBound = (e) => { if (this.seekDragging)
1065
+ this._seekTo(e.clientX); };
1066
+ this._seekMouseUpBound = () => this._endSeekDrag();
1067
+ this._seekTouchMoveBound = (e) => {
1068
+ if (!this.seekDragging)
1069
+ return;
1070
+ e.preventDefault();
1071
+ this._seekTo(e.touches[0].clientX);
1072
+ };
1073
+ this._seekTouchEndBound = () => this._endSeekDrag();
1074
+ this._wireEvents(videoId, config);
1075
+ if (config.forensicWatermark !== false) {
1076
+ const wmText = config.viewerEmail || config.viewerName || '';
1077
+ if (wmText)
1078
+ this._renderWatermark(wmText);
1079
+ }
1080
+ }
1081
+ _wireEvents(videoId, config) {
1082
+ const video = this.videoEl;
1083
+ this.corePlayer = new GuardVideoPlayer(video, videoId, {
1084
+ ...config,
1108
1085
  controls: false,
1109
- className: '',
1110
- style: {},
1111
- hlsConfig,
1112
- branding,
1113
- contextMenuItems,
1114
- security,
1115
- viewerName,
1116
- viewerEmail,
1117
- forensicWatermark,
1086
+ forensicWatermark: config.forensicWatermark !== false,
1118
1087
  onReady: () => {
1119
- onReady?.();
1120
- setTimeout(() => {
1121
- const levels = player.getQualityLevels();
1122
- setQualityLevels(levels);
1123
- }, 100);
1088
+ config.onReady?.();
1089
+ const levels = this.corePlayer.getQualityLevels();
1090
+ if (levels.length) {
1091
+ this.qualityLevels = levels;
1092
+ this._buildQualityMenu();
1093
+ }
1094
+ else {
1095
+ setTimeout(() => {
1096
+ this.qualityLevels = this.corePlayer.getQualityLevels();
1097
+ if (this.qualityLevels.length)
1098
+ this._buildQualityMenu();
1099
+ }, 100);
1100
+ }
1124
1101
  },
1125
1102
  onError: (err) => {
1126
- setError(err);
1127
- onError?.(err);
1103
+ this._showError(err);
1104
+ config.onError?.(err);
1128
1105
  },
1129
1106
  onQualityChange: (quality) => {
1130
- onQualityChange?.(quality);
1131
- const idx = player.getQualityLevels().findIndex(l => l.name === quality);
1132
- setCurrentQualityIdx(idx);
1107
+ const idx = this.qualityLevels.findIndex(l => l.name === quality);
1108
+ this.currentQualityIdx = idx;
1109
+ this._refreshQualityMenu();
1110
+ config.onQualityChange?.(quality);
1133
1111
  },
1134
1112
  onStateChange: (state) => {
1135
- setPlayerState(state);
1136
- onStateChange?.(state);
1113
+ this._onStateChange(state);
1114
+ config.onStateChange?.(state);
1137
1115
  },
1138
1116
  });
1139
- playerRef.current = player;
1140
- return () => {
1141
- player.destroy();
1142
- playerRef.current = null;
1143
- };
1144
- }, [videoId, embedTokenEndpoint]);
1145
- useEffect(() => {
1146
- const video = videoRef.current;
1147
- if (!video)
1148
- return;
1149
- const onTimeUpdate = () => {
1150
- setCurrentTime(video.currentTime);
1151
- onTimeUpdateProp?.(video.currentTime);
1152
- };
1153
- const onDuration = () => setDuration(video.duration || 0);
1154
- const onProgress = () => {
1117
+ video.addEventListener('timeupdate', () => {
1118
+ config.onTimeUpdate?.(video.currentTime);
1119
+ this._onTimeUpdate();
1120
+ });
1121
+ video.addEventListener('loadedmetadata', () => { this.duration = video.duration || 0; this._onTimeUpdate(); });
1122
+ video.addEventListener('durationchange', () => { this.duration = video.duration || 0; });
1123
+ video.addEventListener('progress', () => {
1155
1124
  if (video.buffered.length > 0) {
1156
- setBuffered(video.buffered.end(video.buffered.length - 1));
1125
+ this.bufferedEnd = video.buffered.end(video.buffered.length - 1);
1126
+ this._updateSeekBar();
1127
+ }
1128
+ });
1129
+ video.addEventListener('volumechange', () => this._onVolumeChange());
1130
+ video.addEventListener('ended', () => {
1131
+ config.onEnded?.();
1132
+ this._showControls(true);
1133
+ this._renderPlayBtn();
1134
+ });
1135
+ video.addEventListener('ratechange', () => {
1136
+ this.playbackRate = video.playbackRate;
1137
+ this.speedLabel.textContent = this.playbackRate === 1 ? '1×' : `${this.playbackRate}×`;
1138
+ });
1139
+ this.playBtn.addEventListener('click', (e) => { e.stopPropagation(); this._togglePlay(); });
1140
+ this.volBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleMute(); });
1141
+ this.volSlider.addEventListener('input', () => {
1142
+ video.volume = parseFloat(this.volSlider.value);
1143
+ video.muted = video.volume === 0;
1144
+ });
1145
+ this.seekWrap.addEventListener('mousedown', (e) => {
1146
+ e.preventDefault();
1147
+ this._startSeekDrag();
1148
+ this._seekTo(e.clientX);
1149
+ window.addEventListener('mousemove', this._seekMouseMoveBound);
1150
+ window.addEventListener('mouseup', this._seekMouseUpBound);
1151
+ });
1152
+ this.seekWrap.addEventListener('mousemove', (e) => this._onSeekHover(e.clientX));
1153
+ this.seekWrap.addEventListener('mouseleave', () => { this.seekTooltip.style.opacity = '0'; });
1154
+ this.seekWrap.addEventListener('touchstart', (e) => {
1155
+ e.preventDefault();
1156
+ this._startSeekDrag();
1157
+ this._seekTo(e.touches[0].clientX);
1158
+ window.addEventListener('touchmove', this._seekTouchMoveBound, { passive: false });
1159
+ window.addEventListener('touchend', this._seekTouchEndBound);
1160
+ }, { passive: false });
1161
+ this.seekWrap.addEventListener('keydown', (e) => {
1162
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')
1163
+ return;
1164
+ e.preventDefault();
1165
+ const delta = e.key === 'ArrowLeft' ? -5 : 5;
1166
+ this.corePlayer.seek(Math.max(0, Math.min(this.duration, video.currentTime + delta)));
1167
+ this._resetHideTimer();
1168
+ });
1169
+ this.speedLabel.addEventListener('click', (e) => { e.stopPropagation(); this._toggleMenu('speed'); });
1170
+ this.speedMenu.addEventListener('click', (e) => {
1171
+ e.stopPropagation();
1172
+ const item = e.target.closest('[data-speed]');
1173
+ if (!item)
1174
+ return;
1175
+ video.playbackRate = parseFloat(item.dataset['speed']);
1176
+ this._closeMenus();
1177
+ });
1178
+ this.settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleMenu('quality'); });
1179
+ this.qualityMenu.addEventListener('click', (e) => {
1180
+ e.stopPropagation();
1181
+ const item = e.target.closest('[data-quality]');
1182
+ if (!item)
1183
+ return;
1184
+ const idx = parseInt(item.dataset['quality']);
1185
+ this.corePlayer.setQuality(idx);
1186
+ this.currentQualityIdx = idx;
1187
+ this._refreshQualityMenu();
1188
+ this._closeMenus();
1189
+ });
1190
+ this.clickArea.addEventListener('click', (e) => { this._addRipple(e); this._togglePlay(); });
1191
+ this.centerPlay.addEventListener('click', (e) => { e.stopPropagation(); this._togglePlay(); });
1192
+ this.fsBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleFullscreen(); });
1193
+ document.addEventListener('fullscreenchange', this._onFsChangeBound);
1194
+ this.root.addEventListener('mousemove', () => this._resetHideTimer());
1195
+ this.root.addEventListener('touchstart', () => this._resetHideTimer(), { passive: true });
1196
+ this.root.addEventListener('mouseleave', () => {
1197
+ if (this.playerState === PlayerState.PLAYING)
1198
+ this._showControls(false);
1199
+ });
1200
+ this.root.addEventListener('click', () => this._closeMenus());
1201
+ this.root.addEventListener('keydown', (e) => this._onKeyDown(e));
1202
+ }
1203
+ _onStateChange(state) {
1204
+ this.playerState = state;
1205
+ const loading = state === PlayerState.LOADING || state === PlayerState.BUFFERING;
1206
+ const idle = state === PlayerState.IDLE;
1207
+ const ended = this.videoEl.ended;
1208
+ this.spinner.classList.toggle('gvp-hidden', !loading);
1209
+ const showCenter = (idle || ended) && !loading;
1210
+ this.centerPlay.classList.toggle('gvp-hidden', !showCenter);
1211
+ if (showCenter) {
1212
+ this.centerPlayBtn.innerHTML = '';
1213
+ this.centerPlayBtn.appendChild(svgEl(ended ? ICON.replay : ICON.play, 32, 32));
1214
+ this.centerPlay.setAttribute('aria-label', ended ? 'Replay' : 'Play');
1215
+ }
1216
+ this.clickArea.classList.toggle('gvp-hidden', idle);
1217
+ if (state !== PlayerState.PLAYING) {
1218
+ this._showControls(true);
1219
+ if (this.hideTimer) {
1220
+ clearTimeout(this.hideTimer);
1221
+ this.hideTimer = null;
1157
1222
  }
1158
- };
1159
- const onVolumeChange = () => {
1160
- setVolume(video.volume);
1161
- setMuted(video.muted);
1162
- };
1163
- const onEnded_ = () => {
1164
- onEnded?.();
1165
- setShowControls(true);
1166
- };
1167
- const onRateChange = () => setPlaybackRate(video.playbackRate);
1168
- video.addEventListener('timeupdate', onTimeUpdate);
1169
- video.addEventListener('loadedmetadata', onDuration);
1170
- video.addEventListener('durationchange', onDuration);
1171
- video.addEventListener('progress', onProgress);
1172
- video.addEventListener('volumechange', onVolumeChange);
1173
- video.addEventListener('ended', onEnded_);
1174
- video.addEventListener('ratechange', onRateChange);
1175
- return () => {
1176
- video.removeEventListener('timeupdate', onTimeUpdate);
1177
- video.removeEventListener('loadedmetadata', onDuration);
1178
- video.removeEventListener('durationchange', onDuration);
1179
- video.removeEventListener('progress', onProgress);
1180
- video.removeEventListener('volumechange', onVolumeChange);
1181
- video.removeEventListener('ended', onEnded_);
1182
- video.removeEventListener('ratechange', onRateChange);
1183
- };
1184
- }, [onEnded, onTimeUpdateProp]);
1185
- useEffect(() => {
1186
- if (!forensicWatermark)
1187
- return;
1188
- const t = setTimeout(() => {
1189
- const email = viewerEmail || viewerName || '';
1190
- if (email)
1191
- setWatermarkText(email);
1192
- }, 1500);
1193
- return () => clearTimeout(t);
1194
- }, [forensicWatermark, viewerEmail, viewerName]);
1195
- const resetHideTimer = useCallback(() => {
1196
- setShowControls(true);
1197
- if (hideTimer.current)
1198
- clearTimeout(hideTimer.current);
1199
- hideTimer.current = setTimeout(() => {
1200
- if (playerState === PlayerState.PLAYING)
1201
- setShowControls(false);
1202
- }, 2800);
1203
- }, [playerState]);
1204
- useEffect(() => {
1205
- if (playerState !== PlayerState.PLAYING) {
1206
- setShowControls(true);
1207
- if (hideTimer.current)
1208
- clearTimeout(hideTimer.current);
1209
1223
  }
1210
1224
  else {
1211
- resetHideTimer();
1225
+ this._resetHideTimer();
1212
1226
  }
1213
- return () => { if (hideTimer.current)
1214
- clearTimeout(hideTimer.current); };
1215
- }, [playerState, resetHideTimer]);
1216
- useEffect(() => {
1217
- const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
1218
- document.addEventListener('fullscreenchange', onFsChange);
1219
- return () => document.removeEventListener('fullscreenchange', onFsChange);
1220
- }, []);
1221
- useEffect(() => {
1222
- const onKey = (e) => {
1223
- if (!containerRef.current?.contains(document.activeElement) &&
1224
- document.activeElement !== containerRef.current)
1225
- return;
1226
- switch (e.code) {
1227
- case 'Space':
1228
- case 'KeyK':
1229
- e.preventDefault();
1230
- togglePlay();
1231
- break;
1232
- case 'ArrowLeft':
1233
- e.preventDefault();
1234
- playerRef.current?.seek(Math.max(0, (videoRef.current?.currentTime || 0) - 5));
1235
- break;
1236
- case 'ArrowRight':
1237
- e.preventDefault();
1238
- playerRef.current?.seek(Math.min(duration, (videoRef.current?.currentTime || 0) + 5));
1239
- break;
1240
- case 'ArrowUp':
1241
- e.preventDefault();
1242
- setVolume_(Math.min(1, volume + 0.1));
1243
- break;
1244
- case 'ArrowDown':
1245
- e.preventDefault();
1246
- setVolume_(Math.max(0, volume - 0.1));
1247
- break;
1248
- case 'KeyM':
1249
- e.preventDefault();
1250
- toggleMute();
1251
- break;
1252
- case 'KeyF':
1253
- e.preventDefault();
1254
- toggleFullscreen();
1255
- break;
1256
- }
1257
- };
1258
- document.addEventListener('keydown', onKey);
1259
- return () => document.removeEventListener('keydown', onKey);
1260
- }, [duration, volume]);
1261
- useImperativeHandle(ref, () => ({
1262
- play: () => playerRef.current?.play() || Promise.resolve(),
1263
- pause: () => playerRef.current?.pause(),
1264
- getCurrentTime: () => playerRef.current?.getCurrentTime() || 0,
1265
- seek: (t) => playerRef.current?.seek(t),
1266
- getDuration: () => playerRef.current?.getDuration() || 0,
1267
- getVolume: () => playerRef.current?.getVolume() || 1,
1268
- setVolume: (v) => playerRef.current?.setVolume(v),
1269
- getQualityLevels: () => playerRef.current?.getQualityLevels() || [],
1270
- getCurrentQuality: () => playerRef.current?.getCurrentQuality() || null,
1271
- setQuality: (i) => playerRef.current?.setQuality(i),
1272
- getState: () => playerRef.current?.getState() || PlayerState.IDLE,
1273
- destroy: () => playerRef.current?.destroy(),
1274
- getVideoElement: () => videoRef.current,
1275
- }));
1276
- const togglePlay = useCallback(() => {
1277
- const video = videoRef.current;
1278
- if (!video)
1279
- return;
1280
- if (video.paused || video.ended) {
1281
- playerRef.current?.play().catch(() => { });
1227
+ this._renderPlayBtn();
1228
+ }
1229
+ _togglePlay() {
1230
+ const v = this.videoEl;
1231
+ if (v.paused || v.ended) {
1232
+ this.corePlayer.play().catch(() => { });
1282
1233
  }
1283
1234
  else {
1284
- playerRef.current?.pause();
1235
+ this.corePlayer.pause();
1285
1236
  }
1286
- resetHideTimer();
1287
- }, [resetHideTimer]);
1288
- const setVolume_ = useCallback((v) => {
1289
- if (!videoRef.current)
1237
+ this._resetHideTimer();
1238
+ }
1239
+ _renderPlayBtn() {
1240
+ const ended = this.videoEl.ended;
1241
+ const playing = this.playerState === PlayerState.PLAYING;
1242
+ const icon = ended ? ICON.replay : playing ? ICON.pause : ICON.play;
1243
+ const label = playing ? 'Pause (k)' : 'Play (k)';
1244
+ this.playBtn.innerHTML = '';
1245
+ this.playBtn.appendChild(svgEl(icon));
1246
+ this.playBtn.setAttribute('aria-label', label);
1247
+ this.playBtn.title = label;
1248
+ }
1249
+ _toggleMute() { this.videoEl.muted = !this.videoEl.muted; }
1250
+ _onVolumeChange() {
1251
+ const v = this.videoEl;
1252
+ const muted = v.muted || v.volume === 0;
1253
+ this.volBtn.innerHTML = '';
1254
+ this.volBtn.appendChild(svgEl(muted ? ICON.volMute : ICON.volHigh, 18, 18));
1255
+ this.volBtn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
1256
+ this.volSlider.value = String(muted ? 0 : v.volume);
1257
+ }
1258
+ _startSeekDrag() {
1259
+ this.seekDragging = true;
1260
+ this.seekWrap.classList.add('gvp-dragging');
1261
+ }
1262
+ _endSeekDrag() {
1263
+ this.seekDragging = false;
1264
+ this.seekWrap.classList.remove('gvp-dragging');
1265
+ window.removeEventListener('mousemove', this._seekMouseMoveBound);
1266
+ window.removeEventListener('mouseup', this._seekMouseUpBound);
1267
+ window.removeEventListener('touchmove', this._seekTouchMoveBound);
1268
+ window.removeEventListener('touchend', this._seekTouchEndBound);
1269
+ this._resetHideTimer();
1270
+ }
1271
+ _seekTo(clientX) {
1272
+ if (!this.duration)
1290
1273
  return;
1291
- videoRef.current.volume = v;
1292
- videoRef.current.muted = v === 0;
1293
- }, []);
1294
- const toggleMute = useCallback(() => {
1295
- if (!videoRef.current)
1274
+ const rect = this.seekWrap.getBoundingClientRect();
1275
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1276
+ this.corePlayer.seek(pct * this.duration);
1277
+ }
1278
+ _onSeekHover(clientX) {
1279
+ if (!this.duration)
1296
1280
  return;
1297
- videoRef.current.muted = !videoRef.current.muted;
1298
- }, []);
1299
- const toggleFullscreen = useCallback(() => {
1300
- const el = containerRef.current;
1301
- if (!el)
1281
+ const rect = this.seekWrap.getBoundingClientRect();
1282
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1283
+ this.seekTooltip.textContent = formatTime(pct * this.duration);
1284
+ this.seekTooltip.style.left = `${pct * 100}%`;
1285
+ this.seekTooltip.style.opacity = '1';
1286
+ }
1287
+ _onTimeUpdate() {
1288
+ const ct = this.videoEl.currentTime;
1289
+ const dur = this.duration;
1290
+ this.timeEl.textContent = `${formatTime(ct)} / ${formatTime(dur)}`;
1291
+ this._updateSeekBar();
1292
+ const pct = dur > 0 ? Math.round((ct / dur) * 100) : 0;
1293
+ this.seekWrap.setAttribute('aria-valuenow', String(pct));
1294
+ this.seekWrap.setAttribute('aria-valuetext', `${formatTime(ct)} of ${formatTime(dur)}`);
1295
+ }
1296
+ _updateSeekBar() {
1297
+ const ct = this.videoEl.currentTime;
1298
+ const dur = this.duration;
1299
+ if (!dur)
1300
+ return;
1301
+ const pPct = (ct / dur) * 100;
1302
+ const bPct = (this.bufferedEnd / dur) * 100;
1303
+ this.seekProgress.style.width = `${pPct}%`;
1304
+ this.seekBuffered.style.width = `${bPct}%`;
1305
+ this.seekThumb.style.left = `${pPct}%`;
1306
+ }
1307
+ _buildQualityMenu() {
1308
+ if (!this.qualityLevels.length)
1302
1309
  return;
1310
+ this.settingsBtn.classList.remove('gvp-hidden');
1311
+ this.qualityMenu.innerHTML = '';
1312
+ const title = el('div', 'gvp-menu-title');
1313
+ title.setAttribute('aria-hidden', 'true');
1314
+ title.textContent = 'Quality';
1315
+ const autoItem = el('div', 'gvp-menu-item gvp-menu-item-active');
1316
+ autoItem.setAttribute('role', 'menuitemradio');
1317
+ autoItem.setAttribute('aria-checked', 'true');
1318
+ autoItem.dataset['quality'] = '-1';
1319
+ autoItem.textContent = 'Auto';
1320
+ const checkEl = el('span', 'gvp-menu-check');
1321
+ checkEl.setAttribute('aria-hidden', 'true');
1322
+ checkEl.textContent = '✓';
1323
+ autoItem.appendChild(checkEl);
1324
+ this.qualityMenu.append(title, autoItem, el('div', 'gvp-menu-sep'));
1325
+ [...this.qualityLevels].reverse().forEach(q => {
1326
+ const item = el('div', 'gvp-menu-item');
1327
+ item.setAttribute('role', 'menuitemradio');
1328
+ item.setAttribute('aria-checked', 'false');
1329
+ item.dataset['quality'] = String(q.index);
1330
+ item.textContent = q.name;
1331
+ this.qualityMenu.appendChild(item);
1332
+ });
1333
+ this.currentQualityIdx = -1;
1334
+ }
1335
+ _refreshQualityMenu() {
1336
+ this.qualityMenu.querySelectorAll('[data-quality]').forEach(item => {
1337
+ const active = parseInt(item.dataset['quality']) === this.currentQualityIdx;
1338
+ item.className = active ? 'gvp-menu-item gvp-menu-item-active' : 'gvp-menu-item';
1339
+ item.setAttribute('aria-checked', String(active));
1340
+ const existing = item.querySelector('.gvp-menu-check');
1341
+ if (active && !existing) {
1342
+ const c = el('span', 'gvp-menu-check');
1343
+ c.setAttribute('aria-hidden', 'true');
1344
+ c.textContent = '✓';
1345
+ item.appendChild(c);
1346
+ }
1347
+ else if (!active && existing) {
1348
+ existing.remove();
1349
+ }
1350
+ });
1351
+ }
1352
+ _refreshSpeedMenu() {
1353
+ this.speedMenu.querySelectorAll('[data-speed]').forEach(item => {
1354
+ const active = parseFloat(item.dataset['speed']) === this.playbackRate;
1355
+ item.className = active ? 'gvp-menu-item gvp-menu-item-active' : 'gvp-menu-item';
1356
+ item.setAttribute('aria-checked', String(active));
1357
+ const existing = item.querySelector('.gvp-menu-check');
1358
+ if (active && !existing) {
1359
+ const c = el('span', 'gvp-menu-check');
1360
+ c.setAttribute('aria-hidden', 'true');
1361
+ c.textContent = '✓';
1362
+ item.appendChild(c);
1363
+ }
1364
+ else if (!active && existing) {
1365
+ existing.remove();
1366
+ }
1367
+ });
1368
+ }
1369
+ _toggleMenu(which) {
1370
+ const same = this.openMenu === which;
1371
+ this._closeMenus();
1372
+ if (!same) {
1373
+ this.openMenu = which;
1374
+ if (which === 'speed') {
1375
+ this._refreshSpeedMenu();
1376
+ this.speedMenu.classList.remove('gvp-hidden');
1377
+ }
1378
+ else {
1379
+ this._refreshQualityMenu();
1380
+ this.qualityMenu.classList.remove('gvp-hidden');
1381
+ }
1382
+ }
1383
+ }
1384
+ _closeMenus() {
1385
+ this.openMenu = null;
1386
+ this.speedMenu.classList.add('gvp-hidden');
1387
+ this.qualityMenu.classList.add('gvp-hidden');
1388
+ }
1389
+ _showControls(visible) {
1390
+ this.controls.classList.toggle('gvp-controls-hidden', !visible);
1391
+ this.badge.classList.toggle('gvp-badge-hidden', !visible);
1392
+ }
1393
+ _resetHideTimer() {
1394
+ this._showControls(true);
1395
+ if (this.hideTimer)
1396
+ clearTimeout(this.hideTimer);
1397
+ this.hideTimer = setTimeout(() => {
1398
+ if (this.playerState === PlayerState.PLAYING && !this.openMenu) {
1399
+ this._showControls(false);
1400
+ }
1401
+ }, 2800);
1402
+ }
1403
+ _toggleFullscreen() {
1303
1404
  if (document.fullscreenElement) {
1304
1405
  document.exitFullscreen().catch(() => { });
1305
1406
  }
1306
1407
  else {
1307
- el.requestFullscreen().catch(() => { });
1408
+ this.root.requestFullscreen().catch(() => { });
1308
1409
  }
1309
- }, []);
1310
- const getSeekedTime = (e) => {
1311
- const rect = seekRef.current.getBoundingClientRect();
1312
- const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
1313
- const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1314
- return pct * duration;
1315
- };
1316
- const onSeekClick = (e) => {
1317
- const t = getSeekedTime(e);
1318
- playerRef.current?.seek(t);
1319
- resetHideTimer();
1320
- };
1321
- const onSeekMouseMove = (e) => {
1322
- const rect = seekRef.current.getBoundingClientRect();
1323
- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
1324
- setSeekTooltip({ pct, time: formatTime(pct * duration) });
1325
- };
1326
- const onSeekMouseLeave = () => setSeekTooltip(null);
1327
- const addRipple = (e) => {
1328
- const el = containerRef.current;
1329
- if (!el)
1330
- return;
1331
- const rect = el.getBoundingClientRect();
1410
+ }
1411
+ _onFsChange() {
1412
+ const fs = !!document.fullscreenElement;
1413
+ this.fsBtn.innerHTML = '';
1414
+ this.fsBtn.appendChild(svgEl(fs ? ICON.fsExit : ICON.fsEnter, 18, 18));
1415
+ this.fsBtn.setAttribute('aria-label', fs ? 'Exit fullscreen' : 'Enter fullscreen');
1416
+ this.fsBtn.title = fs ? 'Exit fullscreen (f)' : 'Enter fullscreen (f)';
1417
+ }
1418
+ _showError(err) {
1419
+ this.errorOverlay.children[1].textContent = err.code;
1420
+ this.errorOverlay.children[2].textContent = err.message;
1421
+ this.errorOverlay.classList.remove('gvp-hidden');
1422
+ this.controls.classList.add('gvp-hidden');
1423
+ }
1424
+ _renderWatermark(text) {
1425
+ this.watermarkDiv.innerHTML = '';
1426
+ for (let i = 0; i < 20; i++) {
1427
+ const span = el('span', 'gvp-watermark-text');
1428
+ span.textContent = text;
1429
+ span.style.left = `${(i % 4) * 26 + (Math.floor(i / 4) % 2) * 13}%`;
1430
+ span.style.top = `${Math.floor(i / 4) * 22}%`;
1431
+ this.watermarkDiv.appendChild(span);
1432
+ }
1433
+ }
1434
+ _addRipple(e) {
1435
+ const rect = this.root.getBoundingClientRect();
1332
1436
  const d = document.createElement('div');
1333
1437
  d.className = 'gvp-ripple';
1334
- const size = 60;
1335
- d.style.cssText = `width:${size}px;height:${size}px;left:${e.clientX - rect.left - size / 2}px;top:${e.clientY - rect.top - size / 2}px`;
1336
- el.appendChild(d);
1438
+ const sz = 60;
1439
+ d.style.cssText = `width:${sz}px;height:${sz}px;left:${e.clientX - rect.left - sz / 2}px;top:${e.clientY - rect.top - sz / 2}px`;
1440
+ this.root.appendChild(d);
1337
1441
  setTimeout(() => d.remove(), 600);
1338
- };
1339
- const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
1340
- const bufferedPct = duration > 0 ? (buffered / duration) * 100 : 0;
1341
- const isLoading = playerState === PlayerState.LOADING || playerState === PlayerState.BUFFERING;
1342
- const isIdle = playerState === PlayerState.IDLE;
1343
- const isPlaying = playerState === PlayerState.PLAYING;
1344
- const isEnded = videoRef.current?.ended ?? false;
1345
- const controlsVisible = showControls || !isPlaying;
1346
- const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
1347
- return (React.createElement("div", { ref: containerRef, className: `gvp-root${className ? ` ${className}` : ''}`, style: { width, height, ...style }, tabIndex: 0, onMouseMove: resetHideTimer, onMouseLeave: () => isPlaying && setShowControls(false), onClick: () => setShowMenu(null) },
1348
- React.createElement("video", { ref: videoRef, className: "gvp-video", playsInline: true, preload: "metadata" }),
1349
- React.createElement("div", { className: `gvp-badge${controlsVisible ? '' : ' gvp-badge-hidden'}` },
1350
- React.createElement(ShieldIcon, { color: accentColor }),
1351
- brandName),
1352
- forensicWatermark && watermarkText && (React.createElement("div", { className: "gvp-watermark" }, Array.from({ length: 20 }).map((_, i) => (React.createElement("span", { key: i, className: "gvp-watermark-text", style: {
1353
- left: `${(i % 4) * 26 + ((Math.floor(i / 4)) % 2) * 13}%`,
1354
- top: `${Math.floor(i / 4) * 22}%`,
1355
- } }, watermarkText))))),
1356
- isLoading && (React.createElement("div", { className: "gvp-spinner" },
1357
- React.createElement("div", { className: "gvp-spinner-ring" }))),
1358
- error && (React.createElement("div", { className: "gvp-error" },
1359
- React.createElement("div", { className: "gvp-error-icon" }, "\u26A0\uFE0F"),
1360
- React.createElement("div", { className: "gvp-error-code" }, error.code),
1361
- React.createElement("div", { className: "gvp-error-msg" }, error.message))),
1362
- (isIdle || isEnded) && !error && !isLoading && (React.createElement("div", { className: "gvp-center-play", onClick: togglePlay },
1363
- React.createElement("div", { className: "gvp-center-play-btn" }, isEnded ? React.createElement(ReplayIcon, null) : React.createElement(PlayIcon, null)))),
1364
- !isIdle && !error && (React.createElement("div", { className: "gvp-click-area", onClick: (e) => { addRipple(e); togglePlay(); } })),
1365
- !error && (React.createElement("div", { className: `gvp-controls${controlsVisible ? '' : ' gvp-controls-hidden'}` },
1366
- React.createElement("div", { className: "gvp-seek-row" },
1367
- React.createElement("div", { ref: seekRef, className: "gvp-seek-wrap", onClick: onSeekClick, onMouseMove: onSeekMouseMove, onMouseLeave: onSeekMouseLeave },
1368
- React.createElement("div", { className: "gvp-seek-track" },
1369
- React.createElement("div", { className: "gvp-seek-buffered", style: { width: `${bufferedPct}%` } }),
1370
- React.createElement("div", { className: "gvp-seek-progress", style: { width: `${progressPct}%` } })),
1371
- React.createElement("div", { className: "gvp-seek-thumb", style: { left: `${progressPct}%` } }),
1372
- seekTooltip && (React.createElement("div", { className: "gvp-seek-tooltip", style: { left: `${seekTooltip.pct * 100}%` } }, seekTooltip.time)))),
1373
- React.createElement("div", { className: "gvp-btn-row" },
1374
- React.createElement("button", { className: "gvp-btn", onClick: togglePlay, title: isPlaying ? 'Pause (k)' : 'Play (k)' }, isEnded ? React.createElement(ReplayIcon, null) : isPlaying ? React.createElement(PauseIcon, null) : React.createElement(PlayIcon, null)),
1375
- React.createElement("div", { className: "gvp-volume-wrap" },
1376
- React.createElement("button", { className: "gvp-btn", onClick: toggleMute, title: "Mute (m)" }, muted || volume === 0 ? React.createElement(VolumeMuteIcon, null) : React.createElement(VolumeHighIcon, null)),
1377
- React.createElement("input", { type: "range", className: "gvp-volume-slider", min: 0, max: 1, step: 0.02, value: muted ? 0 : volume, onChange: (e) => setVolume_(parseFloat(e.target.value)), title: "Volume" })),
1378
- React.createElement("span", { className: "gvp-time" },
1379
- formatTime(currentTime),
1380
- "\u00A0",
1381
- React.createElement("span", { style: { opacity: 0.45 } }, "/"),
1382
- "\u00A0",
1383
- formatTime(duration)),
1384
- React.createElement("div", { className: "gvp-spacer" }),
1385
- React.createElement("div", { className: "gvp-menu-wrap" },
1386
- React.createElement("span", { className: "gvp-rate-label", title: "Playback speed", onClick: (e) => { e.stopPropagation(); setShowMenu(showMenu === 'speed' ? null : 'speed'); } }, playbackRate === 1 ? '1×' : `${playbackRate}×`),
1387
- showMenu === 'speed' && (React.createElement("div", { className: "gvp-menu", onClick: (e) => e.stopPropagation() },
1388
- React.createElement("div", { className: "gvp-menu-title" }, "Speed"),
1389
- SPEEDS.map((r) => (React.createElement("div", { key: r, className: `gvp-menu-item${playbackRate === r ? ' gvp-menu-item-active' : ''}`, onClick: () => {
1390
- if (videoRef.current)
1391
- videoRef.current.playbackRate = r;
1392
- setShowMenu(null);
1393
- } },
1394
- r === 1 ? 'Normal' : `${r}×`,
1395
- playbackRate === r && React.createElement("span", { className: "gvp-menu-check" }, "\u2713"))))))),
1396
- qualityLevels.length > 0 && (React.createElement("div", { className: "gvp-menu-wrap" },
1397
- React.createElement("button", { className: "gvp-btn", title: "Quality settings", onClick: (e) => { e.stopPropagation(); setShowMenu(showMenu === 'quality' ? null : 'quality'); } },
1398
- React.createElement(SettingsIcon, null)),
1399
- showMenu === 'quality' && (React.createElement("div", { className: "gvp-menu", onClick: (e) => e.stopPropagation() },
1400
- React.createElement("div", { className: "gvp-menu-title" }, "Quality"),
1401
- React.createElement("div", { className: `gvp-menu-item${currentQualityIdx === -1 ? ' gvp-menu-item-active' : ''}`, onClick: () => {
1402
- playerRef.current?.setQuality(-1);
1403
- setCurrentQualityIdx(-1);
1404
- setShowMenu(null);
1405
- } },
1406
- "Auto",
1407
- currentQualityIdx === -1 && React.createElement("span", { className: "gvp-menu-check" }, "\u2713")),
1408
- React.createElement("div", { className: "gvp-menu-sep" }),
1409
- [...qualityLevels].reverse().map((q) => (React.createElement("div", { key: q.index, className: `gvp-menu-item${currentQualityIdx === q.index ? ' gvp-menu-item-active' : ''}`, onClick: () => {
1410
- playerRef.current?.setQuality(q.index);
1411
- setCurrentQualityIdx(q.index);
1412
- setShowMenu(null);
1413
- } },
1414
- q.name,
1415
- currentQualityIdx === q.index && React.createElement("span", { className: "gvp-menu-check" }, "\u2713")))))))),
1416
- React.createElement("button", { className: "gvp-btn", onClick: toggleFullscreen, title: "Fullscreen (f)" }, isFullscreen ? React.createElement(ExitFullscreenIcon, null) : React.createElement(FullscreenIcon, null)))))));
1442
+ }
1443
+ _onKeyDown(e) {
1444
+ switch (e.code) {
1445
+ case 'Space':
1446
+ case 'KeyK':
1447
+ e.preventDefault();
1448
+ this._togglePlay();
1449
+ break;
1450
+ case 'ArrowLeft':
1451
+ if (document.activeElement !== this.seekWrap) {
1452
+ e.preventDefault();
1453
+ this.corePlayer.seek(Math.max(0, this.videoEl.currentTime - 5));
1454
+ }
1455
+ break;
1456
+ case 'ArrowRight':
1457
+ if (document.activeElement !== this.seekWrap) {
1458
+ e.preventDefault();
1459
+ this.corePlayer.seek(Math.min(this.duration, this.videoEl.currentTime + 5));
1460
+ }
1461
+ break;
1462
+ case 'ArrowUp':
1463
+ e.preventDefault();
1464
+ this.videoEl.volume = Math.min(1, this.videoEl.volume + 0.1);
1465
+ break;
1466
+ case 'ArrowDown':
1467
+ e.preventDefault();
1468
+ this.videoEl.volume = Math.max(0, this.videoEl.volume - 0.1);
1469
+ break;
1470
+ case 'KeyM':
1471
+ e.preventDefault();
1472
+ this._toggleMute();
1473
+ break;
1474
+ case 'KeyF':
1475
+ e.preventDefault();
1476
+ this._toggleFullscreen();
1477
+ break;
1478
+ }
1479
+ }
1480
+ play() { return this.corePlayer.play(); }
1481
+ pause() { return this.corePlayer.pause(); }
1482
+ seek(t) { return this.corePlayer.seek(t); }
1483
+ getCurrentTime() { return this.corePlayer.getCurrentTime(); }
1484
+ getDuration() { return this.corePlayer.getDuration(); }
1485
+ getVolume() { return this.corePlayer.getVolume(); }
1486
+ setVolume(v) { return this.corePlayer.setVolume(v); }
1487
+ getQualityLevels() { return this.corePlayer.getQualityLevels(); }
1488
+ getCurrentQuality() { return this.corePlayer.getCurrentQuality(); }
1489
+ setQuality(i) { return this.corePlayer.setQuality(i); }
1490
+ getState() { return this.corePlayer.getState(); }
1491
+ getVideoElement() { return this.videoEl; }
1492
+ destroy() {
1493
+ if (this.hideTimer)
1494
+ clearTimeout(this.hideTimer);
1495
+ document.removeEventListener('fullscreenchange', this._onFsChangeBound);
1496
+ window.removeEventListener('mousemove', this._seekMouseMoveBound);
1497
+ window.removeEventListener('mouseup', this._seekMouseUpBound);
1498
+ window.removeEventListener('touchmove', this._seekTouchMoveBound);
1499
+ window.removeEventListener('touchend', this._seekTouchEndBound);
1500
+ this.corePlayer.destroy();
1501
+ this.root.remove();
1502
+ }
1503
+ }
1504
+
1505
+ const GuardVideoPlayerComponent = forwardRef((props, ref) => {
1506
+ const { videoId, width = '100%', height = 'auto', className = '', style = {}, onReady, onError, onQualityChange, onStateChange, onTimeUpdate, onEnded, ...playerConfig } = props;
1507
+ const mountRef = useRef(null);
1508
+ const uiRef = useRef(null);
1509
+ const cbRef = useRef({ onReady, onError, onQualityChange, onStateChange, onTimeUpdate, onEnded });
1510
+ cbRef.current = { onReady, onError, onQualityChange, onStateChange, onTimeUpdate, onEnded };
1511
+ useEffect(() => {
1512
+ if (!mountRef.current)
1513
+ return;
1514
+ const config = {
1515
+ ...playerConfig,
1516
+ width: String(width),
1517
+ height: String(height),
1518
+ onReady: () => cbRef.current.onReady?.(),
1519
+ onError: (e) => cbRef.current.onError?.(e),
1520
+ onQualityChange: (q) => cbRef.current.onQualityChange?.(q),
1521
+ onStateChange: (s) => cbRef.current.onStateChange?.(s),
1522
+ onTimeUpdate: (t) => cbRef.current.onTimeUpdate?.(t),
1523
+ onEnded: () => cbRef.current.onEnded?.(),
1524
+ };
1525
+ const ui = new PlayerUI(mountRef.current, videoId, config);
1526
+ uiRef.current = ui;
1527
+ return () => { ui.destroy(); uiRef.current = null; };
1528
+ }, [videoId, playerConfig.embedTokenEndpoint]);
1529
+ useImperativeHandle(ref, () => ({
1530
+ play: () => uiRef.current?.play() ?? Promise.resolve(),
1531
+ pause: () => uiRef.current?.pause(),
1532
+ seek: (t) => uiRef.current?.seek(t),
1533
+ getCurrentTime: () => uiRef.current?.getCurrentTime() ?? 0,
1534
+ getDuration: () => uiRef.current?.getDuration() ?? 0,
1535
+ getVolume: () => uiRef.current?.getVolume() ?? 1,
1536
+ setVolume: (v) => uiRef.current?.setVolume(v),
1537
+ getQualityLevels: () => uiRef.current?.getQualityLevels() ?? [],
1538
+ getCurrentQuality: () => uiRef.current?.getCurrentQuality() ?? null,
1539
+ setQuality: (i) => uiRef.current?.setQuality(i),
1540
+ getState: () => uiRef.current?.getState() ?? PlayerState.IDLE,
1541
+ destroy: () => uiRef.current?.destroy(),
1542
+ getVideoElement: () => uiRef.current?.getVideoElement() ?? null,
1543
+ }));
1544
+ return (React.createElement("div", { ref: mountRef, className: className, style: { width, height, ...style } }));
1417
1545
  });
1418
1546
  GuardVideoPlayerComponent.displayName = 'GuardVideoPlayer';
1419
1547