@guardvideo/player-sdk 1.2.0 → 2.0.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,763 +657,1383 @@ 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
+ volMid: '<path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/>',
695
+ volLow: '<path d="M7 9v6h4l5 5V4l-5 5H7z"/>',
696
+ 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"/>',
697
+ fsEnter: '<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/>',
698
+ fsExit: '<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/>',
699
+ 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"/>',
700
+ shield: '<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'};
701
+ const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
689
702
  let _stylesInjected = false;
690
- function injectPlayerStyles(accentColor) {
703
+ function injectStyles() {
691
704
  if (_stylesInjected)
692
705
  return;
693
706
  _stylesInjected = true;
694
707
  const css = `
695
- /* ── GuardVideo Custom Player ─────────────────────────────── */
708
+ /* ── GuardVideo Player UI v2 ──────────────────────────────── */
709
+
710
+ /* Google Font import — Outfit for labels, DM Mono for time */
711
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Mono:wght@400;500&display=swap');
712
+
713
+ /* ── Root ─────────────────────────────────────────────────── */
696
714
  .gvp-root {
715
+ --gvp-accent: #00e5a0;
716
+ --gvp-accent-dim: rgba(0, 229, 160, 0.18);
717
+ --gvp-accent-glow: rgba(0, 229, 160, 0.35);
718
+ --gvp-glass-bg: rgba(8, 8, 14, 0.72);
719
+ --gvp-glass-bdr: rgba(255,255,255,0.07);
720
+ --gvp-text: rgba(255,255,255,0.92);
721
+ --gvp-text-dim: rgba(255,255,255,0.45);
722
+ --gvp-radius: 12px;
723
+ --gvp-font: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
724
+ --gvp-mono: 'DM Mono', 'SF Mono', 'Fira Mono', monospace;
725
+
697
726
  position: relative;
698
- background: #000;
699
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
700
- border-radius: 10px;
727
+ background: #050508;
728
+ font-family: var(--gvp-font);
729
+ border-radius: var(--gvp-radius);
701
730
  overflow: hidden;
702
731
  -webkit-user-select: none;
732
+ -moz-user-select: none;
733
+ -ms-user-select: none;
703
734
  user-select: none;
704
735
  outline: none;
736
+
737
+ /* Subtle inner vignette for cinema depth */
738
+ box-shadow:
739
+ inset 0 0 80px rgba(0,0,0,0.55),
740
+ 0 24px 80px rgba(0,0,0,0.6);
741
+ }
742
+
743
+ .gvp-root:focus-visible {
744
+ outline: 2px solid var(--gvp-accent);
745
+ outline-offset: 2px;
705
746
  }
706
- .gvp-root:focus-visible { box-shadow: 0 0 0 3px ${accentColor}66; }
707
747
 
748
+ /* ── Video ────────────────────────────────────────────────── */
708
749
  .gvp-video {
709
750
  display: block;
710
751
  width: 100%;
711
752
  height: 100%;
753
+ -o-object-fit: contain;
712
754
  object-fit: contain;
713
- border-radius: 10px;
714
755
  }
715
756
 
716
- /* ── Loading spinner ─────────────────────────────────────── */
717
- .gvp-spinner {
757
+ /* ── Utility ──────────────────────────────────────────────── */
758
+ .gvp-hidden { display: none !important; }
759
+ .gvp-controls-hidden { opacity: 0; -webkit-transform: translateY(8px); transform: translateY(8px); pointer-events: none; }
760
+
761
+ /* ── Gradient overlay (top + bottom) ─────────────────────── */
762
+ .gvp-root::before,
763
+ .gvp-root::after {
764
+ content: '';
718
765
  position: absolute;
719
- inset: 0;
720
- display: flex;
721
- align-items: center;
722
- justify-content: center;
766
+ left: 0; right: 0;
723
767
  pointer-events: none;
724
- background: rgba(0,0,0,0.55);
725
- border-radius: 10px;
768
+ z-index: 2;
769
+ }
770
+ .gvp-root::before {
771
+ top: 0;
772
+ height: 90px;
773
+ background: -webkit-linear-gradient(bottom, transparent, rgba(0,0,0,0.55));
774
+ background: linear-gradient(to bottom, rgba(0,0,0,0.55), transparent);
775
+ border-radius: var(--gvp-radius) var(--gvp-radius) 0 0;
776
+ }
777
+ .gvp-root::after {
778
+ bottom: 0;
779
+ height: 160px;
780
+ background: -webkit-linear-gradient(top, transparent, rgba(0,0,0,0.88));
781
+ background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.88));
782
+ border-radius: 0 0 var(--gvp-radius) var(--gvp-radius);
783
+ }
784
+
785
+ /* ── Spinner ──────────────────────────────────────────────── */
786
+ .gvp-spinner {
787
+ position: absolute; inset: 0;
788
+ display: -webkit-box; display: -ms-flexbox; display: flex;
789
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
790
+ -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center;
791
+ pointer-events: none;
792
+ z-index: 8;
726
793
  }
727
794
  .gvp-spinner-ring {
728
- width: 48px;
729
- height: 48px;
730
- border: 3px solid rgba(255,255,255,0.15);
731
- border-top-color: ${accentColor};
795
+ width: 44px; height: 44px;
796
+ border: 2.5px solid rgba(255,255,255,0.08);
797
+ border-top-color: var(--gvp-accent);
732
798
  border-radius: 50%;
733
- animation: gvp-spin 0.8s linear infinite;
799
+ -webkit-animation: gvp-spin 0.75s linear infinite;
800
+ animation: gvp-spin 0.75s linear infinite;
801
+ box-shadow: 0 0 16px var(--gvp-accent-glow);
734
802
  }
735
- @keyframes gvp-spin { to { transform: rotate(360deg); } }
803
+ @-webkit-keyframes gvp-spin { to { -webkit-transform: rotate(360deg); transform: rotate(360deg); } }
804
+ @keyframes gvp-spin { to { -webkit-transform: rotate(360deg); transform: rotate(360deg); } }
736
805
 
737
- /* ── Big centre play button (initial state) ───────────────── */
806
+ /* ── Centre play overlay ──────────────────────────────────── */
738
807
  .gvp-center-play {
739
- position: absolute;
740
- inset: 0;
741
- display: flex;
742
- align-items: center;
743
- justify-content: center;
808
+ position: absolute; inset: 0; z-index: 6;
809
+ display: -webkit-box; display: -ms-flexbox; display: flex;
810
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
811
+ -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center;
744
812
  cursor: pointer;
745
- background: rgba(0,0,0,0.3);
746
- transition: background 0.2s;
747
- border-radius: 10px;
813
+ background: transparent;
814
+ -webkit-transition: background 0.25s; transition: background 0.25s;
815
+ border-radius: var(--gvp-radius);
748
816
  }
749
- .gvp-center-play:hover { background: rgba(0,0,0,0.45); }
817
+ .gvp-center-play:hover { background: rgba(0,0,0,0.22); }
818
+
750
819
  .gvp-center-play-btn {
751
- width: 72px;
752
- height: 72px;
753
- background: rgba(255,255,255,0.12);
754
- backdrop-filter: blur(8px);
820
+ position: relative;
821
+ width: 76px; height: 76px;
755
822
  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;
823
+ display: -webkit-box; display: -ms-flexbox; display: flex;
824
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
825
+ -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center;
761
826
  color: #fff;
827
+ /* Glass morphism */
828
+ background: rgba(255,255,255,0.10);
829
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
830
+ backdrop-filter: blur(16px) saturate(180%);
831
+ border: 1px solid rgba(255,255,255,0.18);
832
+ -webkit-transition: background 0.2s, -webkit-transform 0.2s, -webkit-box-shadow 0.2s;
833
+ transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
834
+ box-shadow: 0 4px 32px rgba(0,0,0,0.45), 0 0 0 0 var(--gvp-accent-glow);
835
+ }
836
+ /* Ripple ring on hover */
837
+ .gvp-center-play-btn::after {
838
+ content: '';
839
+ position: absolute; inset: -4px;
840
+ border-radius: 50%;
841
+ border: 1.5px solid rgba(255,255,255,0.12);
842
+ -webkit-transition: border-color 0.2s, opacity 0.2s; transition: border-color 0.2s, opacity 0.2s;
843
+ opacity: 0;
844
+ }
845
+ .gvp-center-play:hover .gvp-center-play-btn::after {
846
+ opacity: 1;
847
+ border-color: var(--gvp-accent);
762
848
  }
763
849
  .gvp-center-play:hover .gvp-center-play-btn {
764
- background: ${accentColor}cc;
765
- transform: scale(1.08);
850
+ background: var(--gvp-accent-dim);
851
+ -webkit-transform: scale(1.07);
852
+ transform: scale(1.07);
853
+ box-shadow: 0 4px 40px rgba(0,0,0,0.55), 0 0 28px var(--gvp-accent-glow);
766
854
  }
767
855
 
768
856
  /* ── Click-to-toggle overlay ─────────────────────────────── */
769
857
  .gvp-click-area {
770
- position: absolute;
771
- inset: 0;
772
- cursor: pointer;
773
- z-index: 1;
858
+ position: absolute; inset: 0;
859
+ cursor: pointer; z-index: 4;
774
860
  }
775
861
 
776
- /* ── Ripple effect on click ──────────────────────────────── */
862
+ /* ── Ripple animation ─────────────────────────────────────── */
777
863
  .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;
864
+ position: absolute; border-radius: 50%;
865
+ -webkit-transform: scale(0); transform: scale(0);
866
+ background: rgba(255,255,255,0.18);
867
+ -webkit-animation: gvp-ripple-anim 0.55s cubic-bezier(0.22,1,0.36,1) forwards;
868
+ animation: gvp-ripple-anim 0.55s cubic-bezier(0.22,1,0.36,1) forwards;
869
+ pointer-events: none; z-index: 5;
785
870
  }
786
- @keyframes gvp-ripple-anim { to { transform: scale(4); opacity: 0; } }
871
+ @-webkit-keyframes gvp-ripple-anim { to { -webkit-transform: scale(5); transform: scale(5); opacity: 0; } }
872
+ @keyframes gvp-ripple-anim { to { -webkit-transform: scale(5); transform: scale(5); opacity: 0; } }
787
873
 
788
- /* ── Controls bar ────────────────────────────────────────── */
874
+ /* ── Controls bar ─────────────────────────────────────────── */
789
875
  .gvp-controls {
790
876
  position: absolute;
791
- bottom: 0;
792
- left: 0;
793
- right: 0;
877
+ bottom: 0; left: 0; right: 0;
794
878
  z-index: 10;
795
- background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%);
796
- padding: 36px 14px 10px;
797
- transition: opacity 0.3s, transform 0.3s;
798
- border-radius: 0 0 10px 10px;
879
+ padding: 0 14px 14px;
880
+ -webkit-transition: opacity 0.35s cubic-bezier(0.4,0,0.2,1),
881
+ -webkit-transform 0.35s cubic-bezier(0.4,0,0.2,1);
882
+ transition: opacity 0.35s cubic-bezier(0.4,0,0.2,1),
883
+ transform 0.35s cubic-bezier(0.4,0,0.2,1);
884
+ border-radius: 0 0 var(--gvp-radius) var(--gvp-radius);
799
885
  }
800
- .gvp-controls-hidden {
801
- opacity: 0;
802
- transform: translateY(6px);
803
- pointer-events: none;
886
+
887
+ /* Inner glass pill that wraps all controls */
888
+ .gvp-controls-inner {
889
+ background: var(--gvp-glass-bg);
890
+ -webkit-backdrop-filter: blur(24px) saturate(160%);
891
+ backdrop-filter: blur(24px) saturate(160%);
892
+ border: 1px solid var(--gvp-glass-bdr);
893
+ border-radius: 10px;
894
+ padding: 10px 12px 10px;
895
+ -webkit-box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05);
896
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05);
804
897
  }
805
898
 
806
- /* ── Progress / seek bar ─────────────────────────────────── */
899
+ /* ── Seek row ─────────────────────────────────────────────── */
807
900
  .gvp-seek-row {
808
- display: flex;
809
- align-items: center;
901
+ display: -webkit-box; display: -ms-flexbox; display: flex;
902
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
810
903
  gap: 8px;
811
- margin-bottom: 6px;
904
+ margin-bottom: 8px;
812
905
  }
906
+
813
907
  .gvp-seek-wrap {
814
- flex: 1;
908
+ -webkit-box-flex: 1; -ms-flex: 1; flex: 1;
815
909
  position: relative;
816
910
  height: 4px;
817
911
  cursor: pointer;
818
912
  padding: 8px 0;
819
913
  margin: -8px 0;
820
- box-sizing: content-box;
914
+ -webkit-box-sizing: content-box; box-sizing: content-box;
915
+ touch-action: none;
821
916
  }
917
+
822
918
  .gvp-seek-track {
823
- height: 4px;
824
- background: rgba(255,255,255,0.2);
825
- border-radius: 4px;
919
+ height: 3px;
920
+ background: rgba(255,255,255,0.12);
921
+ border-radius: 99px;
826
922
  position: relative;
827
- overflow: hidden;
828
- transition: height 0.15s;
923
+ overflow: visible;
924
+ -webkit-transition: height 0.15s; transition: height 0.15s;
925
+ pointer-events: none;
829
926
  }
830
- .gvp-seek-wrap:hover .gvp-seek-track { height: 6px; }
927
+ .gvp-seek-wrap:hover .gvp-seek-track,
928
+ .gvp-seek-wrap.gvp-dragging .gvp-seek-track { height: 5px; }
929
+
831
930
  .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;
931
+ position: absolute; left: 0; top: 0; height: 100%;
932
+ background: rgba(255,255,255,0.22);
933
+ border-radius: 99px;
838
934
  pointer-events: none;
935
+ -webkit-transition: width 0.3s; transition: width 0.3s;
839
936
  }
937
+
840
938
  .gvp-seek-progress {
841
- position: absolute;
842
- left: 0;
843
- top: 0;
844
- height: 100%;
845
- background: ${accentColor};
846
- border-radius: 4px;
939
+ position: absolute; left: 0; top: 0; height: 100%;
940
+ background: -webkit-linear-gradient(left, var(--gvp-accent), color-mix(in srgb, var(--gvp-accent) 80%, #fff 20%));
941
+ background: linear-gradient(to right, var(--gvp-accent), color-mix(in srgb, var(--gvp-accent) 80%, #fff 20%));
942
+ border-radius: 99px;
847
943
  pointer-events: none;
848
- transition: width 0.1s linear;
944
+ /* Glow on the progress fill */
945
+ -webkit-box-shadow: 0 0 8px var(--gvp-accent-glow);
946
+ box-shadow: 0 0 8px var(--gvp-accent-glow);
849
947
  }
948
+
949
+ /* Thumb — visible on hover/drag */
850
950
  .gvp-seek-thumb {
851
- position: absolute;
852
- top: 50%;
853
- transform: translate(-50%, -50%);
854
- width: 13px;
855
- height: 13px;
951
+ position: absolute; top: 50%;
952
+ -webkit-transform: translate(-50%, -50%) scale(0);
953
+ transform: translate(-50%, -50%) scale(0);
954
+ width: 14px; height: 14px;
856
955
  background: #fff;
857
956
  border-radius: 50%;
858
- box-shadow: 0 1px 4px rgba(0,0,0,0.5);
859
957
  pointer-events: none;
860
- opacity: 0;
861
- transition: opacity 0.15s;
958
+ -webkit-box-shadow: 0 0 0 3px var(--gvp-accent-dim), 0 2px 6px rgba(0,0,0,0.5);
959
+ box-shadow: 0 0 0 3px var(--gvp-accent-dim), 0 2px 6px rgba(0,0,0,0.5);
960
+ -webkit-transition: -webkit-transform 0.15s cubic-bezier(0.34,1.56,0.64,1);
961
+ transition: transform 0.15s cubic-bezier(0.34,1.56,0.64,1);
962
+ }
963
+ .gvp-seek-wrap:hover .gvp-seek-thumb,
964
+ .gvp-seek-wrap.gvp-dragging .gvp-seek-thumb {
965
+ -webkit-transform: translate(-50%,-50%) scale(1);
966
+ transform: translate(-50%,-50%) scale(1);
862
967
  }
863
- .gvp-seek-wrap:hover .gvp-seek-thumb { opacity: 1; }
864
968
 
865
- /* ── Time tooltip ─────────────────────────────────────────── */
969
+ /* Seek tooltip */
866
970
  .gvp-seek-tooltip {
867
971
  position: absolute;
868
- bottom: 24px;
869
- transform: translateX(-50%);
870
- background: rgba(0,0,0,0.8);
871
- color: #fff;
972
+ bottom: 26px;
973
+ -webkit-transform: translateX(-50%);
974
+ transform: translateX(-50%);
975
+ background: rgba(10,10,14,0.92);
976
+ -webkit-backdrop-filter: blur(8px);
977
+ backdrop-filter: blur(8px);
978
+ border: 1px solid var(--gvp-glass-bdr);
979
+ color: var(--gvp-text);
980
+ font-family: var(--gvp-mono);
872
981
  font-size: 11px;
873
982
  font-weight: 500;
874
- padding: 3px 7px;
875
- border-radius: 4px;
983
+ padding: 3px 8px;
984
+ border-radius: 5px;
876
985
  pointer-events: none;
877
986
  white-space: nowrap;
878
987
  opacity: 0;
879
- transition: opacity 0.1s;
988
+ -webkit-transition: opacity 0.12s; transition: opacity 0.12s;
989
+ -webkit-box-shadow: 0 4px 12px rgba(0,0,0,0.45); box-shadow: 0 4px 12px rgba(0,0,0,0.45);
880
990
  }
881
991
  .gvp-seek-wrap:hover .gvp-seek-tooltip { opacity: 1; }
882
992
 
883
- /* ── Bottom control row ───────────────────────────────────── */
993
+ /* Caret below tooltip */
994
+ .gvp-seek-tooltip::after {
995
+ content: '';
996
+ position: absolute; bottom: -5px; left: 50%;
997
+ -webkit-transform: translateX(-50%); transform: translateX(-50%);
998
+ border: 4px solid transparent;
999
+ border-top-color: rgba(10,10,14,0.92);
1000
+ border-bottom-width: 0;
1001
+ }
1002
+
1003
+ /* ── Button row ───────────────────────────────────────────── */
884
1004
  .gvp-btn-row {
885
- display: flex;
886
- align-items: center;
887
- gap: 4px;
1005
+ display: -webkit-box; display: -ms-flexbox; display: flex;
1006
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1007
+ gap: 2px;
888
1008
  }
1009
+
889
1010
  .gvp-btn {
890
- background: none;
891
- border: none;
892
- color: #fff;
1011
+ background: none; border: none;
1012
+ color: rgba(255,255,255,0.75);
893
1013
  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;
1014
+ padding: 6px;
1015
+ border-radius: 7px;
1016
+ display: -webkit-inline-box; display: -ms-inline-flexbox; display: inline-flex;
1017
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1018
+ -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center;
1019
+ -webkit-transition: background 0.14s, color 0.14s, -webkit-transform 0.12s;
1020
+ transition: background 0.14s, color 0.14s, transform 0.12s;
1021
+ -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0;
1022
+ line-height: 0;
1023
+ }
1024
+ .gvp-btn:hover {
1025
+ background: rgba(255,255,255,0.09);
1026
+ color: #fff;
1027
+ -webkit-transform: scale(1.08); transform: scale(1.08);
1028
+ }
1029
+ .gvp-btn:active {
1030
+ background: rgba(255,255,255,0.14);
1031
+ -webkit-transform: scale(0.96); transform: scale(0.96);
901
1032
  }
902
- .gvp-btn:hover { background: rgba(255,255,255,0.12); color: ${accentColor}; }
903
- .gvp-btn:active { background: rgba(255,255,255,0.2); }
1033
+ /* Accent highlight on play button */
1034
+ .gvp-btn-play:hover { background: var(--gvp-accent-dim); color: var(--gvp-accent); }
904
1035
 
905
- /* ── Volume section ──────────────────────────────────────── */
1036
+ /* ── Volume group ─────────────────────────────────────────── */
906
1037
  .gvp-volume-wrap {
907
- display: flex;
908
- align-items: center;
909
- gap: 5px;
1038
+ display: -webkit-box; display: -ms-flexbox; display: flex;
1039
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1040
+ gap: 2px;
910
1041
  }
911
1042
  .gvp-volume-slider {
912
1043
  -webkit-appearance: none;
913
- width: 70px;
914
- height: 4px;
915
- background: rgba(255,255,255,0.25);
916
- border-radius: 4px;
1044
+ -moz-appearance: none;
1045
+ appearance: none;
1046
+ width: 0;
1047
+ max-width: 72px;
1048
+ height: 3px;
1049
+ background: rgba(255,255,255,0.18);
1050
+ border-radius: 99px;
917
1051
  outline: none;
918
1052
  cursor: pointer;
919
- accent-color: ${accentColor};
920
- transition: width 0.2s;
1053
+ overflow: visible;
1054
+ -webkit-transition: width 0.22s cubic-bezier(0.4,0,0.2,1), opacity 0.22s;
1055
+ transition: width 0.22s cubic-bezier(0.4,0,0.2,1), opacity 0.22s;
1056
+ opacity: 0;
1057
+ /* accent-color is supported in modern browsers */
1058
+ accent-color: var(--gvp-accent);
921
1059
  }
1060
+ /* Show slider when volume-wrap is hovered */
1061
+ .gvp-volume-wrap:hover .gvp-volume-slider,
1062
+ .gvp-volume-wrap:focus-within .gvp-volume-slider {
1063
+ width: 72px;
1064
+ opacity: 1;
1065
+ }
1066
+ /* WebKit thumb */
922
1067
  .gvp-volume-slider::-webkit-slider-thumb {
923
1068
  -webkit-appearance: none;
924
1069
  width: 12px; height: 12px;
925
1070
  border-radius: 50%;
926
1071
  background: #fff;
927
1072
  cursor: pointer;
928
- box-shadow: 0 1px 3px rgba(0,0,0,0.4);
1073
+ -webkit-box-shadow: 0 1px 4px rgba(0,0,0,0.4);
1074
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
929
1075
  }
1076
+ /* Firefox thumb */
930
1077
  .gvp-volume-slider::-moz-range-thumb {
931
1078
  width: 12px; height: 12px;
932
1079
  border-radius: 50%;
933
1080
  background: #fff;
934
1081
  cursor: pointer;
935
1082
  border: none;
1083
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
1084
+ }
1085
+ /* Firefox track */
1086
+ .gvp-volume-slider::-moz-range-track {
1087
+ background: rgba(255,255,255,0.18);
1088
+ border-radius: 99px;
1089
+ height: 3px;
1090
+ }
1091
+ /* Edge/IE thumb */
1092
+ .gvp-volume-slider::-ms-thumb {
1093
+ width: 12px; height: 12px;
1094
+ border-radius: 50%;
1095
+ background: #fff;
1096
+ cursor: pointer;
1097
+ border: none;
936
1098
  }
1099
+ .gvp-volume-slider::-ms-track {
1100
+ background: rgba(255,255,255,0.18);
1101
+ border-color: transparent;
1102
+ color: transparent;
1103
+ height: 3px;
1104
+ }
1105
+ .gvp-volume-slider::-ms-fill-lower { background: var(--gvp-accent); border-radius: 99px; }
937
1106
 
938
- /* ── Time display ─────────────────────────────────────────── */
1107
+ /* ── Time display ──────────────────────────────────────────── */
939
1108
  .gvp-time {
1109
+ font-family: var(--gvp-mono);
940
1110
  font-size: 12px;
941
- color: rgba(255,255,255,0.85);
942
- font-variant-numeric: tabular-nums;
1111
+ font-weight: 500;
1112
+ color: var(--gvp-text);
943
1113
  white-space: nowrap;
944
- letter-spacing: 0.02em;
1114
+ letter-spacing: 0.04em;
1115
+ padding: 0 4px;
1116
+ /* Tabular numerals so digits don't shift width */
1117
+ font-variant-numeric: tabular-nums;
1118
+ -moz-font-feature-settings: "tnum";
1119
+ -webkit-font-feature-settings: "tnum";
1120
+ font-feature-settings: "tnum";
945
1121
  }
1122
+ .gvp-time-sep { color: var(--gvp-text-dim); margin: 0 2px; }
946
1123
 
947
- /* ── Spacer ──────────────────────────────────────────────── */
948
- .gvp-spacer { flex: 1; }
1124
+ .gvp-spacer { -webkit-box-flex: 1; -ms-flex: 1; flex: 1; }
949
1125
 
950
- /* ── Quality / Settings menu ──────────────────────────────── */
951
- .gvp-menu-wrap {
952
- position: relative;
1126
+ /* ── Speed button ─────────────────────────────────────────── */
1127
+ .gvp-rate-label {
1128
+ font-family: var(--gvp-font);
1129
+ font-size: 11px; font-weight: 700;
1130
+ color: var(--gvp-text-dim);
1131
+ min-width: 30px; text-align: center;
1132
+ cursor: pointer;
1133
+ padding: 5px 4px;
1134
+ border-radius: 6px;
1135
+ -webkit-transition: background 0.14s, color 0.14s; transition: background 0.14s, color 0.14s;
1136
+ -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0;
1137
+ letter-spacing: 0.01em;
1138
+ line-height: 1;
953
1139
  }
1140
+ .gvp-rate-label:hover { background: rgba(255,255,255,0.09); color: #fff; }
1141
+ .gvp-rate-label-active { color: var(--gvp-accent); }
1142
+
1143
+ /* ── Popup menus ──────────────────────────────────────────── */
1144
+ .gvp-menu-wrap { position: relative; }
1145
+
954
1146
  .gvp-menu {
955
1147
  position: absolute;
956
- bottom: calc(100% + 8px);
1148
+ bottom: calc(100% + 10px);
957
1149
  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;
1150
+ background: rgba(12,12,18,0.96);
1151
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
1152
+ backdrop-filter: blur(20px) saturate(180%);
1153
+ border: 1px solid var(--gvp-glass-bdr);
1154
+ border-radius: 10px;
1155
+ min-width: 148px;
964
1156
  overflow: hidden;
965
- box-shadow: 0 8px 24px rgba(0,0,0,0.5);
966
- animation: gvp-menu-in 0.1s ease-out;
1157
+ -webkit-box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 0.5px rgba(255,255,255,0.04);
1158
+ box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 0.5px rgba(255,255,255,0.04);
1159
+ -webkit-animation: gvp-menu-in 0.14s cubic-bezier(0.22,1,0.36,1);
1160
+ animation: gvp-menu-in 0.14s cubic-bezier(0.22,1,0.36,1);
967
1161
  z-index: 20;
968
1162
  }
969
- @keyframes gvp-menu-in { from { opacity:0; transform: scale(0.95) translateY(4px); } to { opacity:1; transform: scale(1) translateY(0); } }
1163
+ @-webkit-keyframes gvp-menu-in {
1164
+ from { opacity: 0; -webkit-transform: scale(0.92) translateY(6px); transform: scale(0.92) translateY(6px); }
1165
+ to { opacity: 1; -webkit-transform: none; transform: none; }
1166
+ }
1167
+ @keyframes gvp-menu-in {
1168
+ from { opacity: 0; -webkit-transform: scale(0.92) translateY(6px); transform: scale(0.92) translateY(6px); }
1169
+ to { opacity: 1; -webkit-transform: none; transform: none; }
1170
+ }
1171
+
970
1172
  .gvp-menu-title {
971
- font-size: 10px;
972
- font-weight: 600;
1173
+ font-family: var(--gvp-font);
1174
+ font-size: 10px; font-weight: 700;
973
1175
  text-transform: uppercase;
974
- letter-spacing: 0.08em;
975
- color: rgba(255,255,255,0.4);
976
- padding: 8px 12px 4px;
1176
+ letter-spacing: 0.12em;
1177
+ color: var(--gvp-text-dim);
1178
+ padding: 10px 13px 5px;
977
1179
  }
1180
+
978
1181
  .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);
1182
+ display: -webkit-box; display: -ms-flexbox; display: flex;
1183
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1184
+ -webkit-box-pack: justify; -ms-flex-pack: justify; justify-content: space-between;
1185
+ padding: 7px 13px;
1186
+ font-family: var(--gvp-font);
1187
+ font-size: 13px; font-weight: 500;
1188
+ color: var(--gvp-text);
985
1189
  cursor: pointer;
986
- transition: background 0.12s;
987
- gap: 10px;
1190
+ -webkit-transition: background 0.1s; transition: background 0.1s;
1191
+ gap: 12px;
1192
+ }
1193
+ .gvp-menu-item:hover { background: rgba(255,255,255,0.06); }
1194
+ .gvp-menu-item-active { color: var(--gvp-accent); }
1195
+
1196
+ .gvp-menu-check {
1197
+ font-size: 13px;
1198
+ color: var(--gvp-accent);
1199
+ line-height: 1;
1200
+ /* Unicode checkmark — crisp on all platforms */
1201
+ }
1202
+
1203
+ .gvp-menu-sep {
1204
+ height: 1px;
1205
+ background: rgba(255,255,255,0.06);
1206
+ margin: 3px 0;
988
1207
  }
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; }
993
1208
 
994
1209
  /* ── Error overlay ────────────────────────────────────────── */
995
1210
  .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);
1211
+ position: absolute; inset: 0; z-index: 15;
1212
+ display: -webkit-box; display: -ms-flexbox; display: flex;
1213
+ -webkit-box-orient: vertical; -webkit-box-direction: normal;
1214
+ -ms-flex-direction: column; flex-direction: column;
1215
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1216
+ -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center;
1217
+ background: rgba(6,6,10,0.88);
1218
+ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
1003
1219
  color: #fff;
1004
1220
  gap: 10px;
1005
- padding: 24px;
1006
- border-radius: 10px;
1221
+ padding: 28px;
1222
+ border-radius: var(--gvp-radius);
1007
1223
  text-align: center;
1008
1224
  }
1009
- .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; }
1225
+ .gvp-error-icon { font-size: 40px; line-height: 1; }
1226
+ .gvp-error-code {
1227
+ font-family: var(--gvp-mono);
1228
+ font-size: 10px; font-weight: 500;
1229
+ letter-spacing: 0.14em;
1230
+ color: #f87171;
1231
+ text-transform: uppercase;
1232
+ background: rgba(248,113,113,0.1);
1233
+ border: 1px solid rgba(248,113,113,0.25);
1234
+ padding: 3px 10px; border-radius: 4px;
1235
+ }
1236
+ .gvp-error-msg {
1237
+ font-family: var(--gvp-font);
1238
+ font-size: 14px; font-weight: 400;
1239
+ color: rgba(255,255,255,0.6);
1240
+ max-width: 320px; line-height: 1.6;
1241
+ }
1012
1242
 
1013
1243
  /* ── Secure badge ─────────────────────────────────────────── */
1014
1244
  .gvp-badge {
1015
- position: absolute;
1016
- top: 10px;
1017
- right: 12px;
1018
- display: flex;
1019
- align-items: center;
1245
+ position: absolute; top: 12px; right: 14px; z-index: 5;
1246
+ display: -webkit-inline-box; display: -ms-inline-flexbox; display: inline-flex;
1247
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1020
1248
  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};
1249
+ background: rgba(8,8,14,0.60);
1250
+ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
1251
+ border: 1px solid rgba(255,255,255,0.08);
1252
+ border-radius: 99px;
1253
+ padding: 4px 10px 4px 7px;
1254
+ font-family: var(--gvp-font);
1255
+ font-size: 10px; font-weight: 700;
1256
+ color: var(--gvp-accent);
1028
1257
  pointer-events: none;
1029
- z-index: 5;
1030
- letter-spacing: 0.04em;
1031
- transition: opacity 0.3s;
1258
+ letter-spacing: 0.06em;
1259
+ text-transform: uppercase;
1260
+ -webkit-transition: opacity 0.3s; transition: opacity 0.3s;
1261
+ -webkit-box-shadow: 0 2px 12px rgba(0,0,0,0.4), 0 0 0 0.5px rgba(255,255,255,0.05);
1262
+ box-shadow: 0 2px 12px rgba(0,0,0,0.4), 0 0 0 0.5px rgba(255,255,255,0.05);
1032
1263
  }
1033
1264
  .gvp-badge-hidden { opacity: 0; }
1034
1265
 
1035
- /* ── Forensic watermark strip ─────────────────────────────── */
1266
+ /* ── Forensic watermark ───────────────────────────────────── */
1036
1267
  .gvp-watermark {
1268
+ position: absolute; inset: 0;
1269
+ pointer-events: none; overflow: hidden; z-index: 6;
1270
+ }
1271
+ .gvp-watermark-text {
1037
1272
  position: absolute;
1038
- inset: 0;
1273
+ white-space: nowrap;
1274
+ font-family: var(--gvp-mono);
1275
+ font-size: 12px;
1276
+ color: rgba(255,255,255,0.055);
1277
+ -webkit-transform: rotate(-28deg); transform: rotate(-28deg);
1278
+ -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;
1039
1279
  pointer-events: none;
1040
- overflow: hidden;
1041
- z-index: 6;
1280
+ letter-spacing: 0.06em;
1042
1281
  }
1043
- .gvp-watermark-text {
1282
+
1283
+ /* ── Live dot (for live streams) ─────────────────────────── */
1284
+ .gvp-live-badge {
1285
+ display: -webkit-inline-box; display: -ms-inline-flexbox; display: inline-flex;
1286
+ -webkit-box-align: center; -ms-flex-align: center; align-items: center;
1287
+ gap: 5px;
1288
+ font-family: var(--gvp-font);
1289
+ font-size: 11px; font-weight: 700;
1290
+ color: #f87171;
1291
+ letter-spacing: 0.08em;
1292
+ text-transform: uppercase;
1293
+ }
1294
+ .gvp-live-dot {
1295
+ width: 7px; height: 7px;
1296
+ background: #f87171;
1297
+ border-radius: 50%;
1298
+ -webkit-animation: gvp-live-pulse 1.5s ease-in-out infinite;
1299
+ animation: gvp-live-pulse 1.5s ease-in-out infinite;
1300
+ }
1301
+ @-webkit-keyframes gvp-live-pulse {
1302
+ 0%, 100% { opacity: 1; -webkit-transform: scale(1); transform: scale(1); }
1303
+ 50% { opacity: 0.5; -webkit-transform: scale(0.7); transform: scale(0.7); }
1304
+ }
1305
+ @keyframes gvp-live-pulse {
1306
+ 0%, 100% { opacity: 1; transform: scale(1); }
1307
+ 50% { opacity: 0.5; transform: scale(0.7); }
1308
+ }
1309
+
1310
+ /* ── Divider between button groups ───────────────────────── */
1311
+ .gvp-divider {
1312
+ width: 1px; height: 18px;
1313
+ background: rgba(255,255,255,0.1);
1314
+ margin: 0 4px;
1315
+ -webkit-flex-shrink: 0; -ms-flex-negative: 0; flex-shrink: 0;
1316
+ }
1317
+
1318
+ /* ── Tooltip on buttons ───────────────────────────────────── */
1319
+ .gvp-btn[title]:hover::after {
1320
+ content: attr(title);
1044
1321
  position: absolute;
1322
+ bottom: calc(100% + 7px);
1323
+ left: 50%; -webkit-transform: translateX(-50%); transform: translateX(-50%);
1324
+ background: rgba(10,10,14,0.95);
1325
+ border: 1px solid var(--gvp-glass-bdr);
1326
+ color: var(--gvp-text);
1327
+ font-family: var(--gvp-font);
1328
+ font-size: 11px; font-weight: 500;
1329
+ padding: 3px 8px; border-radius: 5px;
1045
1330
  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
1331
  pointer-events: none;
1052
- letter-spacing: 0.05em;
1332
+ z-index: 30;
1053
1333
  }
1054
1334
 
1055
- /* ── Playback rate menu ────────────────────────────────────── */
1056
- .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;
1335
+ /* ── Focus ring (keyboard nav) ────────────────────────────── */
1336
+ .gvp-btn:focus-visible,
1337
+ .gvp-rate-label:focus-visible,
1338
+ .gvp-seek-wrap:focus-visible {
1339
+ outline: 2px solid var(--gvp-accent);
1340
+ outline-offset: 2px;
1341
+ }
1342
+
1343
+ /* ── Responsive: hide volume slider text on tiny players ─── */
1344
+ @media (max-width: 380px) {
1345
+ .gvp-volume-slider { display: none; }
1346
+ .gvp-time { font-size: 11px; }
1347
+ .gvp-controls-inner { padding: 8px 10px; }
1067
1348
  }
1068
- .gvp-rate-label:hover { background: rgba(255,255,255,0.1); }
1069
1349
  `;
1070
1350
  const tag = document.createElement('style');
1071
- tag.setAttribute('data-guardvideo', 'player-ui-styles');
1351
+ tag.setAttribute('data-guardvideo', 'player-ui-styles-v2');
1072
1352
  tag.textContent = css;
1073
1353
  document.head.appendChild(tag);
1074
1354
  }
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,
1355
+ class PlayerUI {
1356
+ constructor(container, videoId, config) {
1357
+ this.playerState = PlayerState.IDLE;
1358
+ this.duration = 0;
1359
+ this.bufferedEnd = 0;
1360
+ this.qualityLevels = [];
1361
+ this.currentQualityIdx = -1;
1362
+ this.playbackRate = 1;
1363
+ this.openMenu = null;
1364
+ this.hideTimer = null;
1365
+ this.seekDragging = false;
1366
+ const accent = config.branding?.accentColor ?? '#00e5a0';
1367
+ const brandName = config.branding?.name ?? 'GuardVideo';
1368
+ injectStyles();
1369
+ container.innerHTML = '';
1370
+ this.root = el('div', 'gvp-root');
1371
+ this.root.style.width = config.width ?? '100%';
1372
+ this.root.style.height = config.height ?? 'auto';
1373
+ this.root.style.setProperty('--gvp-accent', accent);
1374
+ this.root.style.setProperty('--gvp-accent-dim', this._hexToRgba(accent, 0.18));
1375
+ this.root.style.setProperty('--gvp-accent-glow', this._hexToRgba(accent, 0.35));
1376
+ this.root.setAttribute('tabindex', '0');
1377
+ this.root.setAttribute('role', 'region');
1378
+ this.root.setAttribute('aria-label', `${brandName} video player`);
1379
+ this.videoEl = el('video', 'gvp-video', { playsinline: '', preload: 'metadata' });
1380
+ this.badge = el('div', 'gvp-badge');
1381
+ this.badge.setAttribute('aria-hidden', 'true');
1382
+ this.badge.appendChild(svgEl(ICON.shield, 13, 13));
1383
+ this.badge.appendChild(document.createTextNode(brandName));
1384
+ this.watermarkDiv = el('div', 'gvp-watermark');
1385
+ this.watermarkDiv.setAttribute('aria-hidden', 'true');
1386
+ this.spinner = el('div', 'gvp-spinner gvp-hidden');
1387
+ this.spinner.setAttribute('aria-label', 'Loading');
1388
+ this.spinner.setAttribute('role', 'status');
1389
+ this.spinner.appendChild(el('div', 'gvp-spinner-ring'));
1390
+ this.errorOverlay = el('div', 'gvp-error gvp-hidden');
1391
+ this.errorOverlay.setAttribute('role', 'alert');
1392
+ const errIcon = el('div', 'gvp-error-icon');
1393
+ errIcon.textContent = '⚠️';
1394
+ errIcon.setAttribute('aria-hidden', 'true');
1395
+ const errCode = el('div', 'gvp-error-code');
1396
+ const errMsg = el('div', 'gvp-error-msg');
1397
+ this.errorOverlay.append(errIcon, errCode, errMsg);
1398
+ this.centerPlay = el('div', 'gvp-center-play');
1399
+ this.centerPlay.setAttribute('role', 'button');
1400
+ this.centerPlay.setAttribute('aria-label', 'Play');
1401
+ this.centerPlayBtn = el('div', 'gvp-center-play-btn');
1402
+ this.centerPlayBtn.appendChild(svgEl(ICON.play, 30, 30));
1403
+ this.centerPlay.appendChild(this.centerPlayBtn);
1404
+ this.clickArea = el('div', 'gvp-click-area gvp-hidden');
1405
+ this.clickArea.setAttribute('aria-hidden', 'true');
1406
+ this.controls = el('div', 'gvp-controls');
1407
+ const inner = el('div', 'gvp-controls-inner');
1408
+ const seekRow = el('div', 'gvp-seek-row');
1409
+ this.seekWrap = el('div', 'gvp-seek-wrap');
1410
+ this.seekWrap.setAttribute('role', 'slider');
1411
+ this.seekWrap.setAttribute('aria-label', 'Seek');
1412
+ this.seekWrap.setAttribute('aria-valuemin', '0');
1413
+ this.seekWrap.setAttribute('aria-valuemax', '100');
1414
+ this.seekWrap.setAttribute('aria-valuenow', '0');
1415
+ this.seekWrap.setAttribute('aria-valuetext', '0:00 of 0:00');
1416
+ this.seekWrap.setAttribute('tabindex', '0');
1417
+ const seekTrack = el('div', 'gvp-seek-track');
1418
+ this.seekBuffered = el('div', 'gvp-seek-buffered');
1419
+ this.seekProgress = el('div', 'gvp-seek-progress');
1420
+ this.seekThumb = el('div', 'gvp-seek-thumb');
1421
+ this.seekTooltip = el('div', 'gvp-seek-tooltip');
1422
+ this.seekTooltip.setAttribute('aria-hidden', 'true');
1423
+ seekTrack.append(this.seekBuffered, this.seekProgress);
1424
+ this.seekWrap.append(seekTrack, this.seekThumb, this.seekTooltip);
1425
+ seekRow.appendChild(this.seekWrap);
1426
+ inner.appendChild(seekRow);
1427
+ const btnRow = el('div', 'gvp-btn-row');
1428
+ this.playBtn = el('button', 'gvp-btn gvp-btn-play');
1429
+ this.playBtn.type = 'button';
1430
+ this.playBtn.setAttribute('aria-label', 'Play');
1431
+ this.playBtn.title = 'Play (k)';
1432
+ this.playBtn.appendChild(svgEl(ICON.play));
1433
+ const volWrap = el('div', 'gvp-volume-wrap');
1434
+ this.volBtn = el('button', 'gvp-btn');
1435
+ this.volBtn.type = 'button';
1436
+ this.volBtn.setAttribute('aria-label', 'Mute');
1437
+ this.volBtn.title = 'Mute (m)';
1438
+ this.volBtn.appendChild(svgEl(ICON.volHigh, 18, 18));
1439
+ this.volSlider = el('input', 'gvp-volume-slider');
1440
+ Object.assign(this.volSlider, { type: 'range', min: '0', max: '1', step: '0.02', value: '1' });
1441
+ this.volSlider.setAttribute('aria-label', 'Volume');
1442
+ volWrap.append(this.volBtn, this.volSlider);
1443
+ this.timeEl = el('span', 'gvp-time');
1444
+ this.timeEl.setAttribute('aria-live', 'off');
1445
+ this.timeEl.textContent = '0:00 / 0:00';
1446
+ const spacer = el('div', 'gvp-spacer');
1447
+ const divider = el('div', 'gvp-divider');
1448
+ divider.setAttribute('aria-hidden', 'true');
1449
+ const speedWrap = el('div', 'gvp-menu-wrap');
1450
+ this.speedLabel = el('span', 'gvp-rate-label');
1451
+ this.speedLabel.textContent = '1×';
1452
+ this.speedLabel.setAttribute('role', 'button');
1453
+ this.speedLabel.setAttribute('aria-label', 'Playback speed');
1454
+ this.speedLabel.setAttribute('aria-haspopup', 'menu');
1455
+ this.speedLabel.setAttribute('tabindex', '0');
1456
+ this.speedMenu = el('div', 'gvp-menu gvp-hidden');
1457
+ this.speedMenu.setAttribute('role', 'menu');
1458
+ this.speedMenu.setAttribute('aria-label', 'Playback speed');
1459
+ const speedTitle = el('div', 'gvp-menu-title');
1460
+ speedTitle.setAttribute('aria-hidden', 'true');
1461
+ speedTitle.textContent = 'Speed';
1462
+ this.speedMenu.appendChild(speedTitle);
1463
+ SPEEDS.forEach(r => {
1464
+ const active = r === 1;
1465
+ const item = el('div', active ? 'gvp-menu-item gvp-menu-item-active' : 'gvp-menu-item');
1466
+ item.setAttribute('role', 'menuitemradio');
1467
+ item.setAttribute('aria-checked', active ? 'true' : 'false');
1468
+ item.dataset['speed'] = String(r);
1469
+ item.textContent = r === 1 ? 'Normal' : `${r}×`;
1470
+ if (active) {
1471
+ const check = el('span', 'gvp-menu-check');
1472
+ check.setAttribute('aria-hidden', 'true');
1473
+ check.textContent = '✓';
1474
+ item.appendChild(check);
1475
+ }
1476
+ this.speedMenu.appendChild(item);
1477
+ });
1478
+ speedWrap.append(this.speedLabel, this.speedMenu);
1479
+ const qualWrap = el('div', 'gvp-menu-wrap');
1480
+ this.settingsBtn = el('button', 'gvp-btn');
1481
+ this.settingsBtn.type = 'button';
1482
+ this.settingsBtn.setAttribute('aria-label', 'Quality settings');
1483
+ this.settingsBtn.setAttribute('aria-haspopup', 'menu');
1484
+ this.settingsBtn.title = 'Quality';
1485
+ this.settingsBtn.appendChild(svgEl(ICON.settings, 18, 18));
1486
+ this.settingsBtn.classList.add('gvp-hidden');
1487
+ this.qualityMenu = el('div', 'gvp-menu gvp-hidden');
1488
+ this.qualityMenu.setAttribute('role', 'menu');
1489
+ this.qualityMenu.setAttribute('aria-label', 'Video quality');
1490
+ qualWrap.append(this.settingsBtn, this.qualityMenu);
1491
+ this.fsBtn = el('button', 'gvp-btn');
1492
+ this.fsBtn.type = 'button';
1493
+ this.fsBtn.setAttribute('aria-label', 'Enter fullscreen');
1494
+ this.fsBtn.title = 'Fullscreen (f)';
1495
+ this.fsBtn.appendChild(svgEl(ICON.fsEnter, 18, 18));
1496
+ btnRow.append(this.playBtn, volWrap, this.timeEl, spacer, speedWrap, divider, qualWrap, this.fsBtn);
1497
+ inner.appendChild(btnRow);
1498
+ this.controls.appendChild(inner);
1499
+ this.root.append(this.videoEl, this.badge, this.watermarkDiv, this.spinner, this.errorOverlay, this.centerPlay, this.clickArea, this.controls);
1500
+ container.appendChild(this.root);
1501
+ this._onFsChangeBound = () => this._onFsChange();
1502
+ this._seekMouseMoveBound = (e) => { if (this.seekDragging)
1503
+ this._seekTo(e.clientX); };
1504
+ this._seekMouseUpBound = () => this._endSeekDrag();
1505
+ this._seekTouchMoveBound = (e) => {
1506
+ if (!this.seekDragging)
1507
+ return;
1508
+ e.preventDefault();
1509
+ this._seekTo(e.touches[0].clientX);
1510
+ };
1511
+ this._seekTouchEndBound = () => this._endSeekDrag();
1512
+ this._wireEvents(videoId, config);
1513
+ if (config.forensicWatermark !== false) {
1514
+ const wmText = config.viewerEmail || config.viewerName || '';
1515
+ if (wmText)
1516
+ this._renderWatermark(wmText);
1517
+ }
1518
+ }
1519
+ _hexToRgba(hex, alpha) {
1520
+ const clean = hex.replace('#', '');
1521
+ const full = clean.length === 3
1522
+ ? clean.split('').map(c => c + c).join('')
1523
+ : clean;
1524
+ const r = parseInt(full.substring(0, 2), 16);
1525
+ const g = parseInt(full.substring(2, 4), 16);
1526
+ const b = parseInt(full.substring(4, 6), 16);
1527
+ if (isNaN(r) || isNaN(g) || isNaN(b))
1528
+ return `rgba(0,229,160,${alpha})`;
1529
+ return `rgba(${r},${g},${b},${alpha})`;
1530
+ }
1531
+ _wireEvents(videoId, config) {
1532
+ const video = this.videoEl;
1533
+ this.corePlayer = new GuardVideoPlayer(video, videoId, {
1534
+ ...config,
1108
1535
  controls: false,
1109
- className: '',
1110
- style: {},
1111
- hlsConfig,
1112
- branding,
1113
- contextMenuItems,
1114
- security,
1115
- viewerName,
1116
- viewerEmail,
1117
- forensicWatermark,
1536
+ forensicWatermark: config.forensicWatermark !== false,
1118
1537
  onReady: () => {
1119
- onReady?.();
1120
- setTimeout(() => {
1121
- const levels = player.getQualityLevels();
1122
- setQualityLevels(levels);
1123
- }, 100);
1538
+ config.onReady?.();
1539
+ const levels = this.corePlayer.getQualityLevels();
1540
+ if (levels.length) {
1541
+ this.qualityLevels = levels;
1542
+ this._buildQualityMenu();
1543
+ }
1544
+ else {
1545
+ setTimeout(() => {
1546
+ this.qualityLevels = this.corePlayer.getQualityLevels();
1547
+ if (this.qualityLevels.length)
1548
+ this._buildQualityMenu();
1549
+ }, 100);
1550
+ }
1124
1551
  },
1125
1552
  onError: (err) => {
1126
- setError(err);
1127
- onError?.(err);
1553
+ this._showError(err);
1554
+ config.onError?.(err);
1128
1555
  },
1129
1556
  onQualityChange: (quality) => {
1130
- onQualityChange?.(quality);
1131
- const idx = player.getQualityLevels().findIndex(l => l.name === quality);
1132
- setCurrentQualityIdx(idx);
1557
+ const idx = this.qualityLevels.findIndex(l => l.name === quality);
1558
+ this.currentQualityIdx = idx;
1559
+ this._refreshQualityMenu();
1560
+ config.onQualityChange?.(quality);
1133
1561
  },
1134
1562
  onStateChange: (state) => {
1135
- setPlayerState(state);
1136
- onStateChange?.(state);
1563
+ this._onStateChange(state);
1564
+ config.onStateChange?.(state);
1137
1565
  },
1138
1566
  });
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 = () => {
1567
+ video.addEventListener('timeupdate', () => {
1568
+ config.onTimeUpdate?.(video.currentTime);
1569
+ this._onTimeUpdate();
1570
+ });
1571
+ video.addEventListener('loadedmetadata', () => {
1572
+ this.duration = video.duration || 0;
1573
+ this._onTimeUpdate();
1574
+ });
1575
+ video.addEventListener('durationchange', () => {
1576
+ this.duration = video.duration || 0;
1577
+ });
1578
+ video.addEventListener('progress', () => {
1155
1579
  if (video.buffered.length > 0) {
1156
- setBuffered(video.buffered.end(video.buffered.length - 1));
1580
+ this.bufferedEnd = video.buffered.end(video.buffered.length - 1);
1581
+ this._updateSeekBar();
1582
+ }
1583
+ });
1584
+ video.addEventListener('volumechange', () => this._onVolumeChange());
1585
+ video.addEventListener('ended', () => {
1586
+ config.onEnded?.();
1587
+ this._showControls(true);
1588
+ this._renderPlayBtn();
1589
+ });
1590
+ video.addEventListener('ratechange', () => {
1591
+ this.playbackRate = video.playbackRate;
1592
+ const isNormal = this.playbackRate === 1;
1593
+ this.speedLabel.textContent = isNormal ? '1×' : `${this.playbackRate}×`;
1594
+ this.speedLabel.classList.toggle('gvp-rate-label-active', !isNormal);
1595
+ });
1596
+ this.playBtn.addEventListener('click', (e) => { e.stopPropagation(); this._togglePlay(); });
1597
+ this.volBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleMute(); });
1598
+ this.volSlider.addEventListener('input', () => {
1599
+ video.volume = parseFloat(this.volSlider.value);
1600
+ video.muted = video.volume === 0;
1601
+ });
1602
+ this.seekWrap.addEventListener('mousedown', (e) => {
1603
+ e.preventDefault();
1604
+ this._startSeekDrag();
1605
+ this._seekTo(e.clientX);
1606
+ window.addEventListener('mousemove', this._seekMouseMoveBound);
1607
+ window.addEventListener('mouseup', this._seekMouseUpBound);
1608
+ });
1609
+ this.seekWrap.addEventListener('mousemove', (e) => this._onSeekHover(e.clientX));
1610
+ this.seekWrap.addEventListener('mouseleave', () => {
1611
+ this.seekTooltip.style.opacity = '0';
1612
+ });
1613
+ this.seekWrap.addEventListener('touchstart', (e) => {
1614
+ e.preventDefault();
1615
+ this._startSeekDrag();
1616
+ this._seekTo(e.touches[0].clientX);
1617
+ window.addEventListener('touchmove', this._seekTouchMoveBound, { passive: false });
1618
+ window.addEventListener('touchend', this._seekTouchEndBound);
1619
+ }, { passive: false });
1620
+ this.seekWrap.addEventListener('keydown', (e) => {
1621
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')
1622
+ return;
1623
+ e.preventDefault();
1624
+ const delta = e.key === 'ArrowLeft' ? -5 : 5;
1625
+ this.corePlayer.seek(Math.max(0, Math.min(this.duration, video.currentTime + delta)));
1626
+ this._resetHideTimer();
1627
+ });
1628
+ this.speedLabel.addEventListener('click', (e) => { e.stopPropagation(); this._toggleMenu('speed'); });
1629
+ this.speedLabel.addEventListener('keydown', (e) => {
1630
+ if (e.key === 'Enter' || e.key === ' ') {
1631
+ e.preventDefault();
1632
+ this._toggleMenu('speed');
1633
+ }
1634
+ });
1635
+ this.speedMenu.addEventListener('click', (e) => {
1636
+ e.stopPropagation();
1637
+ const item = e.target.closest('[data-speed]');
1638
+ if (!item)
1639
+ return;
1640
+ video.playbackRate = parseFloat(item.dataset['speed']);
1641
+ this._closeMenus();
1642
+ });
1643
+ this.settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleMenu('quality'); });
1644
+ this.qualityMenu.addEventListener('click', (e) => {
1645
+ e.stopPropagation();
1646
+ const item = e.target.closest('[data-quality]');
1647
+ if (!item)
1648
+ return;
1649
+ const idx = parseInt(item.dataset['quality']);
1650
+ this.corePlayer.setQuality(idx);
1651
+ this.currentQualityIdx = idx;
1652
+ this._refreshQualityMenu();
1653
+ this._closeMenus();
1654
+ });
1655
+ this.clickArea.addEventListener('click', (e) => { this._addRipple(e); this._togglePlay(); });
1656
+ this.centerPlay.addEventListener('click', (e) => { e.stopPropagation(); this._togglePlay(); });
1657
+ this.fsBtn.addEventListener('click', (e) => { e.stopPropagation(); this._toggleFullscreen(); });
1658
+ document.addEventListener('fullscreenchange', this._onFsChangeBound);
1659
+ document.addEventListener('webkitfullscreenchange', this._onFsChangeBound);
1660
+ document.addEventListener('mozfullscreenchange', this._onFsChangeBound);
1661
+ document.addEventListener('MSFullscreenChange', this._onFsChangeBound);
1662
+ this.root.addEventListener('mousemove', () => this._resetHideTimer());
1663
+ this.root.addEventListener('touchstart', () => this._resetHideTimer(), { passive: true });
1664
+ this.root.addEventListener('mouseleave', () => {
1665
+ if (this.playerState === PlayerState.PLAYING)
1666
+ this._showControls(false);
1667
+ });
1668
+ this.root.addEventListener('click', () => this._closeMenus());
1669
+ this.root.addEventListener('keydown', (e) => this._onKeyDown(e));
1670
+ }
1671
+ _onStateChange(state) {
1672
+ this.playerState = state;
1673
+ const loading = state === PlayerState.LOADING || state === PlayerState.BUFFERING;
1674
+ const idle = state === PlayerState.IDLE;
1675
+ const ended = this.videoEl.ended;
1676
+ this.spinner.classList.toggle('gvp-hidden', !loading);
1677
+ const showCenter = (idle || ended) && !loading;
1678
+ this.centerPlay.classList.toggle('gvp-hidden', !showCenter);
1679
+ if (showCenter) {
1680
+ this.centerPlayBtn.innerHTML = '';
1681
+ this.centerPlayBtn.appendChild(svgEl(ended ? ICON.replay : ICON.play, 30, 30));
1682
+ this.centerPlay.setAttribute('aria-label', ended ? 'Replay' : 'Play');
1683
+ }
1684
+ this.clickArea.classList.toggle('gvp-hidden', idle);
1685
+ if (state !== PlayerState.PLAYING) {
1686
+ this._showControls(true);
1687
+ if (this.hideTimer) {
1688
+ clearTimeout(this.hideTimer);
1689
+ this.hideTimer = null;
1157
1690
  }
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
1691
  }
1210
1692
  else {
1211
- resetHideTimer();
1693
+ this._resetHideTimer();
1212
1694
  }
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(() => { });
1695
+ this._renderPlayBtn();
1696
+ }
1697
+ _togglePlay() {
1698
+ const v = this.videoEl;
1699
+ if (v.paused || v.ended) {
1700
+ this.corePlayer.play().catch(() => { });
1282
1701
  }
1283
1702
  else {
1284
- playerRef.current?.pause();
1703
+ this.corePlayer.pause();
1285
1704
  }
1286
- resetHideTimer();
1287
- }, [resetHideTimer]);
1288
- const setVolume_ = useCallback((v) => {
1289
- if (!videoRef.current)
1705
+ this._resetHideTimer();
1706
+ }
1707
+ _renderPlayBtn() {
1708
+ const ended = this.videoEl.ended;
1709
+ const playing = this.playerState === PlayerState.PLAYING;
1710
+ const icon = ended ? ICON.replay : playing ? ICON.pause : ICON.play;
1711
+ const label = playing ? 'Pause' : 'Play';
1712
+ this.playBtn.innerHTML = '';
1713
+ this.playBtn.appendChild(svgEl(icon));
1714
+ this.playBtn.setAttribute('aria-label', label);
1715
+ this.playBtn.title = `${label} (k)`;
1716
+ }
1717
+ _toggleMute() { this.videoEl.muted = !this.videoEl.muted; }
1718
+ _onVolumeChange() {
1719
+ const v = this.videoEl;
1720
+ const vol = v.muted ? 0 : v.volume;
1721
+ const muted = vol === 0;
1722
+ const icon = muted ? ICON.volMute : vol < 0.4 ? ICON.volLow : vol < 0.75 ? ICON.volMid : ICON.volHigh;
1723
+ this.volBtn.innerHTML = '';
1724
+ this.volBtn.appendChild(svgEl(icon, 18, 18));
1725
+ this.volBtn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
1726
+ this.volBtn.title = muted ? 'Unmute (m)' : 'Mute (m)';
1727
+ this.volSlider.value = String(vol);
1728
+ }
1729
+ _startSeekDrag() {
1730
+ this.seekDragging = true;
1731
+ this.seekWrap.classList.add('gvp-dragging');
1732
+ }
1733
+ _endSeekDrag() {
1734
+ this.seekDragging = false;
1735
+ this.seekWrap.classList.remove('gvp-dragging');
1736
+ window.removeEventListener('mousemove', this._seekMouseMoveBound);
1737
+ window.removeEventListener('mouseup', this._seekMouseUpBound);
1738
+ window.removeEventListener('touchmove', this._seekTouchMoveBound);
1739
+ window.removeEventListener('touchend', this._seekTouchEndBound);
1740
+ this._resetHideTimer();
1741
+ }
1742
+ _seekTo(clientX) {
1743
+ if (!this.duration)
1290
1744
  return;
1291
- videoRef.current.volume = v;
1292
- videoRef.current.muted = v === 0;
1293
- }, []);
1294
- const toggleMute = useCallback(() => {
1295
- if (!videoRef.current)
1745
+ const rect = this.seekWrap.getBoundingClientRect();
1746
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1747
+ this.corePlayer.seek(pct * this.duration);
1748
+ }
1749
+ _onSeekHover(clientX) {
1750
+ if (!this.duration)
1296
1751
  return;
1297
- videoRef.current.muted = !videoRef.current.muted;
1298
- }, []);
1299
- const toggleFullscreen = useCallback(() => {
1300
- const el = containerRef.current;
1301
- if (!el)
1752
+ const rect = this.seekWrap.getBoundingClientRect();
1753
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1754
+ this.seekTooltip.textContent = formatTime(pct * this.duration);
1755
+ this.seekTooltip.style.left = `${pct * 100}%`;
1756
+ this.seekTooltip.style.opacity = '1';
1757
+ }
1758
+ _onTimeUpdate() {
1759
+ const ct = this.videoEl.currentTime;
1760
+ const dur = this.duration;
1761
+ this.timeEl.textContent = `${formatTime(ct)} / ${formatTime(dur)}`;
1762
+ this._updateSeekBar();
1763
+ const pct = dur > 0 ? Math.round((ct / dur) * 100) : 0;
1764
+ this.seekWrap.setAttribute('aria-valuenow', String(pct));
1765
+ this.seekWrap.setAttribute('aria-valuetext', `${formatTime(ct)} of ${formatTime(dur)}`);
1766
+ }
1767
+ _updateSeekBar() {
1768
+ const ct = this.videoEl.currentTime;
1769
+ const dur = this.duration;
1770
+ if (!dur)
1302
1771
  return;
1303
- if (document.fullscreenElement) {
1304
- document.exitFullscreen().catch(() => { });
1772
+ const pPct = (ct / dur) * 100;
1773
+ const bPct = (this.bufferedEnd / dur) * 100;
1774
+ this.seekProgress.style.width = `${pPct}%`;
1775
+ this.seekBuffered.style.width = `${bPct}%`;
1776
+ this.seekThumb.style.left = `${pPct}%`;
1777
+ }
1778
+ _buildQualityMenu() {
1779
+ if (!this.qualityLevels.length)
1780
+ return;
1781
+ this.settingsBtn.classList.remove('gvp-hidden');
1782
+ this.qualityMenu.innerHTML = '';
1783
+ const title = el('div', 'gvp-menu-title');
1784
+ title.setAttribute('aria-hidden', 'true');
1785
+ title.textContent = 'Quality';
1786
+ const autoItem = el('div', 'gvp-menu-item gvp-menu-item-active');
1787
+ autoItem.setAttribute('role', 'menuitemradio');
1788
+ autoItem.setAttribute('aria-checked', 'true');
1789
+ autoItem.dataset['quality'] = '-1';
1790
+ autoItem.textContent = 'Auto';
1791
+ const checkEl = el('span', 'gvp-menu-check');
1792
+ checkEl.setAttribute('aria-hidden', 'true');
1793
+ checkEl.textContent = '✓';
1794
+ autoItem.appendChild(checkEl);
1795
+ this.qualityMenu.append(title, autoItem, el('div', 'gvp-menu-sep'));
1796
+ [...this.qualityLevels].reverse().forEach(q => {
1797
+ const item = el('div', 'gvp-menu-item');
1798
+ item.setAttribute('role', 'menuitemradio');
1799
+ item.setAttribute('aria-checked', 'false');
1800
+ item.dataset['quality'] = String(q.index);
1801
+ item.textContent = q.name;
1802
+ this.qualityMenu.appendChild(item);
1803
+ });
1804
+ this.currentQualityIdx = -1;
1805
+ }
1806
+ _refreshQualityMenu() {
1807
+ this.qualityMenu.querySelectorAll('[data-quality]').forEach(item => {
1808
+ const active = parseInt(item.dataset['quality']) === this.currentQualityIdx;
1809
+ item.className = active ? 'gvp-menu-item gvp-menu-item-active' : 'gvp-menu-item';
1810
+ item.setAttribute('aria-checked', String(active));
1811
+ const existing = item.querySelector('.gvp-menu-check');
1812
+ if (active && !existing) {
1813
+ const c = el('span', 'gvp-menu-check');
1814
+ c.setAttribute('aria-hidden', 'true');
1815
+ c.textContent = '✓';
1816
+ item.appendChild(c);
1817
+ }
1818
+ else if (!active && existing) {
1819
+ existing.remove();
1820
+ }
1821
+ });
1822
+ }
1823
+ _refreshSpeedMenu() {
1824
+ this.speedMenu.querySelectorAll('[data-speed]').forEach(item => {
1825
+ const active = parseFloat(item.dataset['speed']) === this.playbackRate;
1826
+ item.className = active ? 'gvp-menu-item gvp-menu-item-active' : 'gvp-menu-item';
1827
+ item.setAttribute('aria-checked', String(active));
1828
+ const existing = item.querySelector('.gvp-menu-check');
1829
+ if (active && !existing) {
1830
+ const c = el('span', 'gvp-menu-check');
1831
+ c.setAttribute('aria-hidden', 'true');
1832
+ c.textContent = '✓';
1833
+ item.appendChild(c);
1834
+ }
1835
+ else if (!active && existing) {
1836
+ existing.remove();
1837
+ }
1838
+ });
1839
+ }
1840
+ _toggleMenu(which) {
1841
+ const same = this.openMenu === which;
1842
+ this._closeMenus();
1843
+ if (!same) {
1844
+ this.openMenu = which;
1845
+ if (which === 'speed') {
1846
+ this._refreshSpeedMenu();
1847
+ this.speedMenu.classList.remove('gvp-hidden');
1848
+ }
1849
+ else {
1850
+ this._refreshQualityMenu();
1851
+ this.qualityMenu.classList.remove('gvp-hidden');
1852
+ }
1853
+ }
1854
+ }
1855
+ _closeMenus() {
1856
+ this.openMenu = null;
1857
+ this.speedMenu.classList.add('gvp-hidden');
1858
+ this.qualityMenu.classList.add('gvp-hidden');
1859
+ }
1860
+ _showControls(visible) {
1861
+ this.controls.classList.toggle('gvp-controls-hidden', !visible);
1862
+ this.badge.classList.toggle('gvp-badge-hidden', !visible);
1863
+ }
1864
+ _resetHideTimer() {
1865
+ this._showControls(true);
1866
+ if (this.hideTimer)
1867
+ clearTimeout(this.hideTimer);
1868
+ this.hideTimer = setTimeout(() => {
1869
+ if (this.playerState === PlayerState.PLAYING && !this.openMenu) {
1870
+ this._showControls(false);
1871
+ }
1872
+ }, 2800);
1873
+ }
1874
+ _toggleFullscreen() {
1875
+ const doc = document;
1876
+ const root = this.root;
1877
+ const isFs = !!(document.fullscreenElement ||
1878
+ doc.webkitFullscreenElement ||
1879
+ doc.mozFullScreenElement ||
1880
+ doc.msFullscreenElement);
1881
+ if (isFs) {
1882
+ (document.exitFullscreen?.() ||
1883
+ doc.webkitExitFullscreen?.() ||
1884
+ doc.mozCancelFullScreen?.() ||
1885
+ (doc.msExitFullscreen?.(), Promise.resolve()))
1886
+ ?.catch?.(() => { });
1305
1887
  }
1306
1888
  else {
1307
- el.requestFullscreen().catch(() => { });
1889
+ (root.requestFullscreen?.() ||
1890
+ root.webkitRequestFullscreen?.() ||
1891
+ root.mozRequestFullScreen?.() ||
1892
+ (root.msRequestFullscreen?.(), Promise.resolve()))
1893
+ ?.catch?.(() => { });
1308
1894
  }
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();
1895
+ }
1896
+ _onFsChange() {
1897
+ const doc = document;
1898
+ const fs = !!(document.fullscreenElement ||
1899
+ doc.webkitFullscreenElement ||
1900
+ doc.mozFullScreenElement ||
1901
+ doc.msFullscreenElement);
1902
+ this.fsBtn.innerHTML = '';
1903
+ this.fsBtn.appendChild(svgEl(fs ? ICON.fsExit : ICON.fsEnter, 18, 18));
1904
+ this.fsBtn.setAttribute('aria-label', fs ? 'Exit fullscreen' : 'Enter fullscreen');
1905
+ this.fsBtn.title = fs ? 'Exit fullscreen (f)' : 'Enter fullscreen (f)';
1906
+ }
1907
+ _showError(err) {
1908
+ this.errorOverlay.children[1].textContent = err.code;
1909
+ this.errorOverlay.children[2].textContent = err.message;
1910
+ this.errorOverlay.classList.remove('gvp-hidden');
1911
+ this.controls.classList.add('gvp-hidden');
1912
+ }
1913
+ _renderWatermark(text) {
1914
+ this.watermarkDiv.innerHTML = '';
1915
+ for (let i = 0; i < 20; i++) {
1916
+ const span = el('span', 'gvp-watermark-text');
1917
+ span.textContent = text;
1918
+ span.style.left = `${(i % 4) * 26 + (Math.floor(i / 4) % 2) * 13}%`;
1919
+ span.style.top = `${Math.floor(i / 4) * 22}%`;
1920
+ this.watermarkDiv.appendChild(span);
1921
+ }
1922
+ }
1923
+ _addRipple(e) {
1924
+ const rect = this.root.getBoundingClientRect();
1332
1925
  const d = document.createElement('div');
1333
1926
  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);
1337
- 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)))))));
1927
+ const sz = 60;
1928
+ 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`;
1929
+ this.root.appendChild(d);
1930
+ setTimeout(() => d.remove(), 650);
1931
+ }
1932
+ _onKeyDown(e) {
1933
+ switch (e.code) {
1934
+ case 'Space':
1935
+ case 'KeyK':
1936
+ e.preventDefault();
1937
+ this._togglePlay();
1938
+ break;
1939
+ case 'ArrowLeft':
1940
+ if (document.activeElement !== this.seekWrap) {
1941
+ e.preventDefault();
1942
+ this.corePlayer.seek(Math.max(0, this.videoEl.currentTime - 5));
1943
+ }
1944
+ break;
1945
+ case 'ArrowRight':
1946
+ if (document.activeElement !== this.seekWrap) {
1947
+ e.preventDefault();
1948
+ this.corePlayer.seek(Math.min(this.duration, this.videoEl.currentTime + 5));
1949
+ }
1950
+ break;
1951
+ case 'ArrowUp':
1952
+ e.preventDefault();
1953
+ this.videoEl.volume = Math.min(1, this.videoEl.volume + 0.1);
1954
+ break;
1955
+ case 'ArrowDown':
1956
+ e.preventDefault();
1957
+ this.videoEl.volume = Math.max(0, this.videoEl.volume - 0.1);
1958
+ break;
1959
+ case 'KeyM':
1960
+ e.preventDefault();
1961
+ this._toggleMute();
1962
+ break;
1963
+ case 'KeyF':
1964
+ e.preventDefault();
1965
+ this._toggleFullscreen();
1966
+ break;
1967
+ }
1968
+ }
1969
+ play() { return this.corePlayer.play(); }
1970
+ pause() { return this.corePlayer.pause(); }
1971
+ seek(t) { return this.corePlayer.seek(t); }
1972
+ getCurrentTime() { return this.corePlayer.getCurrentTime(); }
1973
+ getDuration() { return this.corePlayer.getDuration(); }
1974
+ getVolume() { return this.corePlayer.getVolume(); }
1975
+ setVolume(v) { return this.corePlayer.setVolume(v); }
1976
+ getQualityLevels() { return this.corePlayer.getQualityLevels(); }
1977
+ getCurrentQuality() { return this.corePlayer.getCurrentQuality(); }
1978
+ setQuality(i) { return this.corePlayer.setQuality(i); }
1979
+ getState() { return this.corePlayer.getState(); }
1980
+ getVideoElement() { return this.videoEl; }
1981
+ destroy() {
1982
+ if (this.hideTimer)
1983
+ clearTimeout(this.hideTimer);
1984
+ document.removeEventListener('fullscreenchange', this._onFsChangeBound);
1985
+ document.removeEventListener('webkitfullscreenchange', this._onFsChangeBound);
1986
+ document.removeEventListener('mozfullscreenchange', this._onFsChangeBound);
1987
+ document.removeEventListener('MSFullscreenChange', this._onFsChangeBound);
1988
+ window.removeEventListener('mousemove', this._seekMouseMoveBound);
1989
+ window.removeEventListener('mouseup', this._seekMouseUpBound);
1990
+ window.removeEventListener('touchmove', this._seekTouchMoveBound);
1991
+ window.removeEventListener('touchend', this._seekTouchEndBound);
1992
+ this.corePlayer.destroy();
1993
+ this.root.remove();
1994
+ }
1995
+ }
1996
+
1997
+ const GuardVideoPlayerComponent = forwardRef((props, ref) => {
1998
+ const { videoId, width = '100%', height = 'auto', className = '', style = {}, onReady, onError, onQualityChange, onStateChange, onTimeUpdate, onEnded, ...playerConfig } = props;
1999
+ const mountRef = useRef(null);
2000
+ const uiRef = useRef(null);
2001
+ const cbRef = useRef({ onReady, onError, onQualityChange, onStateChange, onTimeUpdate, onEnded });
2002
+ cbRef.current = { onReady, onError, onQualityChange, onStateChange, onTimeUpdate, onEnded };
2003
+ useEffect(() => {
2004
+ if (!mountRef.current)
2005
+ return;
2006
+ const config = {
2007
+ ...playerConfig,
2008
+ width: String(width),
2009
+ height: String(height),
2010
+ onReady: () => cbRef.current.onReady?.(),
2011
+ onError: (e) => cbRef.current.onError?.(e),
2012
+ onQualityChange: (q) => cbRef.current.onQualityChange?.(q),
2013
+ onStateChange: (s) => cbRef.current.onStateChange?.(s),
2014
+ onTimeUpdate: (t) => cbRef.current.onTimeUpdate?.(t),
2015
+ onEnded: () => cbRef.current.onEnded?.(),
2016
+ };
2017
+ const ui = new PlayerUI(mountRef.current, videoId, config);
2018
+ uiRef.current = ui;
2019
+ return () => { ui.destroy(); uiRef.current = null; };
2020
+ }, [videoId, playerConfig.embedTokenEndpoint]);
2021
+ useImperativeHandle(ref, () => ({
2022
+ play: () => uiRef.current?.play() ?? Promise.resolve(),
2023
+ pause: () => uiRef.current?.pause(),
2024
+ seek: (t) => uiRef.current?.seek(t),
2025
+ getCurrentTime: () => uiRef.current?.getCurrentTime() ?? 0,
2026
+ getDuration: () => uiRef.current?.getDuration() ?? 0,
2027
+ getVolume: () => uiRef.current?.getVolume() ?? 1,
2028
+ setVolume: (v) => uiRef.current?.setVolume(v),
2029
+ getQualityLevels: () => uiRef.current?.getQualityLevels() ?? [],
2030
+ getCurrentQuality: () => uiRef.current?.getCurrentQuality() ?? null,
2031
+ setQuality: (i) => uiRef.current?.setQuality(i),
2032
+ getState: () => uiRef.current?.getState() ?? PlayerState.IDLE,
2033
+ destroy: () => uiRef.current?.destroy(),
2034
+ getVideoElement: () => uiRef.current?.getVideoElement() ?? null,
2035
+ }));
2036
+ return (React.createElement("div", { ref: mountRef, className: className, style: { width, height, ...style } }));
1417
2037
  });
1418
2038
  GuardVideoPlayerComponent.displayName = 'GuardVideoPlayer';
1419
2039