@kodezen/player 1.0.2 → 1.0.4

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.
@@ -96,8 +96,7 @@ var Html5Provider = class {
96
96
  }
97
97
  }
98
98
  play() {
99
- void this.video.play().catch(() => {
100
- });
99
+ void this.video.play().catch(() => this.emitter.emit("pause"));
101
100
  }
102
101
  pause() {
103
102
  this.video.pause();
@@ -379,8 +378,8 @@ var VimeoProvider = class {
379
378
  this.emitter.emit("ready");
380
379
  }
381
380
  play() {
382
- var _a, _b;
383
- void ((_b = (_a = this.player) == null ? void 0 : _a.play) == null ? void 0 : _b.call(_a));
381
+ var _a, _b, _c, _d;
382
+ (_d = (_c = (_b = (_a = this.player) == null ? void 0 : _a.play) == null ? void 0 : _b.call(_a)) == null ? void 0 : _c.catch) == null ? void 0 : _d.call(_c, () => this.emitter.emit("pause"));
384
383
  }
385
384
  pause() {
386
385
  var _a, _b;
@@ -450,13 +449,124 @@ function createProvider(source) {
450
449
  }
451
450
  }
452
451
 
452
+ // src/core/Captions.ts
453
+ var TIMING = /^\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s+-->\s+((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})/;
454
+ var ENTITIES = {
455
+ amp: "&",
456
+ lt: "<",
457
+ gt: ">",
458
+ quot: '"',
459
+ apos: "'",
460
+ nbsp: "\xA0",
461
+ lrm: "\u200E",
462
+ rlm: "\u200F"
463
+ };
464
+ function toSeconds(stamp) {
465
+ const [clock = "", fraction = "0"] = stamp.replace(",", ".").split(".");
466
+ const seconds = clock.split(":").reduce((total, part) => total * 60 + Number(part), 0);
467
+ return seconds + Number(`0.${fraction}`);
468
+ }
469
+ function plainText(text) {
470
+ return text.replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|apos|nbsp|lrm|rlm);/g, (_, name) => {
471
+ var _a;
472
+ return (_a = ENTITIES[name]) != null ? _a : "";
473
+ }).replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
474
+ }
475
+ function parseVtt(input) {
476
+ var _a;
477
+ const cues = [];
478
+ const blocks = input.replace(/^/, "").replace(/\r\n?/g, "\n").split(/\n{2,}/);
479
+ for (const block of blocks) {
480
+ const lines = block.split("\n");
481
+ const timingAt = lines.findIndex((line) => line.includes("-->"));
482
+ const timing = TIMING.exec((_a = lines[timingAt]) != null ? _a : "");
483
+ if (!(timing == null ? void 0 : timing[1]) || !timing[2]) continue;
484
+ const text = plainText(lines.slice(timingAt + 1).join("\n")).trim();
485
+ if (!text) continue;
486
+ cues.push({ start: toSeconds(timing[1]), end: toSeconds(timing[2]), text });
487
+ }
488
+ return cues.sort((a, b) => a.start - b.start);
489
+ }
490
+ var Captions = class {
491
+ /** `onChange` fires when the active track or track availability changes. */
492
+ constructor(tracks, onChange) {
493
+ this.onChange = onChange;
494
+ this.active = -1;
495
+ this.time = 0;
496
+ this.abort = new AbortController();
497
+ this.el = document.createElement("div");
498
+ this.el.className = "kzp-captions";
499
+ this.el.setAttribute("aria-live", "off");
500
+ this.text = document.createElement("span");
501
+ this.text.className = "kzp-captions-text";
502
+ this.text.hidden = true;
503
+ this.el.appendChild(this.text);
504
+ this.tracks = tracks.map((info) => ({ info, cues: null, failed: false }));
505
+ const preferred = tracks.findIndex((t) => t.default);
506
+ this.tracks.forEach((track, index) => {
507
+ void this.load(track).then(() => {
508
+ if (index === preferred && this.active === -1 && track.cues) this.select(index);
509
+ else this.onChange();
510
+ });
511
+ });
512
+ }
513
+ async load(track) {
514
+ try {
515
+ const res = await fetch(track.info.src, { signal: this.abort.signal, credentials: "same-origin" });
516
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
517
+ track.cues = parseVtt(await res.text());
518
+ } catch (err) {
519
+ if (this.abort.signal.aborted) return;
520
+ track.failed = true;
521
+ console.warn(`@kodezen/player: could not load captions from ${track.info.src}`, err);
522
+ }
523
+ }
524
+ /** Labels for the menu, one per track (falls back to the language, then a number). */
525
+ labels() {
526
+ return this.tracks.map((t, i) => t.info.label || t.info.srclang || `Track ${i + 1}`);
527
+ }
528
+ /** Whether each track loaded (a failed track is hidden from the controls). */
529
+ available() {
530
+ return this.tracks.map((t) => !t.failed);
531
+ }
532
+ getActive() {
533
+ return this.active;
534
+ }
535
+ /** Show track `index`, or turn captions off with -1. */
536
+ select(index) {
537
+ var _a;
538
+ const track = this.tracks[index];
539
+ this.active = track && !track.failed ? index : -1;
540
+ this.el.lang = (_a = track == null ? void 0 : track.info.srclang) != null ? _a : "";
541
+ this.render();
542
+ this.onChange();
543
+ }
544
+ /** Called with the playhead position on every tick and after a seek. */
545
+ update(time) {
546
+ this.time = time;
547
+ this.render();
548
+ }
549
+ render() {
550
+ var _a, _b;
551
+ const cues = (_b = (_a = this.tracks[this.active]) == null ? void 0 : _a.cues) != null ? _b : [];
552
+ const text = cues.filter((cue) => this.time >= cue.start && this.time < cue.end).map((cue) => cue.text).join("\n");
553
+ if (text !== this.text.textContent) this.text.textContent = text;
554
+ this.text.hidden = !text;
555
+ }
556
+ destroy() {
557
+ this.abort.abort();
558
+ this.el.remove();
559
+ }
560
+ };
561
+
453
562
  // src/core/Controls.ts
454
563
  var ICONS = {
455
564
  play: '<path d="M8 5v14l11-7z"/>',
456
565
  pause: '<path d="M6 5h4v14H6zM14 5h4v14h-4z"/>',
457
566
  volume: '<path d="M3 10v4h4l5 5V5L7 10H3zm13.5 2a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/>',
458
567
  muted: '<path d="M3 10v4h4l5 5V5L7 10H3zm13 2 3 3 1-1-3-3 3-3-1-1-3 3-3-3-1 1 3 3-3 3 1 1z"/>',
459
- full: '<path d="M4 4h6v2H6v4H4V4zm10 0h6v6h-2V6h-4V4zM4 14h2v4h4v2H4v-6zm14 0h2v6h-6v-2h4v-4z"/>'
568
+ full: '<path d="M4 4h6v2H6v4H4V4zm10 0h6v6h-2V6h-4V4zM4 14h2v4h4v2H4v-6zm14 0h2v6h-6v-2h4v-4z"/>',
569
+ captions: '<path d="M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1z"/>'
460
570
  };
461
571
  function icon(paths) {
462
572
  return `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
@@ -476,20 +586,34 @@ var DEFAULT_CONFIG = {
476
586
  time: true,
477
587
  volume: true,
478
588
  speed: true,
589
+ captions: true,
479
590
  fullscreen: true
480
591
  };
481
592
  var Controls = class {
482
593
  constructor(cb, opts) {
483
594
  this.cb = cb;
595
+ this.ccActive = -1;
596
+ // Icons currently drawn, so update() only rewrites a button's SVG when it
597
+ // actually changes (see update()).
598
+ this.playIcon = "";
599
+ this.volIcon = "";
484
600
  this.scrubbing = false;
601
+ this.rate = 0;
602
+ this.onOutsideClick = (e) => {
603
+ if (this.el.contains(e.target)) return;
604
+ if (this.speedMenu) this.speedMenu.hidden = true;
605
+ if (this.ccMenu) this.ccMenu.hidden = true;
606
+ };
485
607
  this.seekableMax = Infinity;
608
+ var _a;
486
609
  this.config = { ...DEFAULT_CONFIG, ...opts.config };
487
610
  this.lockSeek = opts.lockSeek;
488
611
  this.el = document.createElement("div");
489
612
  this.el.className = "kzp-controls";
490
- this.build(opts.speeds);
613
+ this.build(opts.speeds, (_a = opts.captionLabels) != null ? _a : []);
614
+ document.addEventListener("click", this.onOutsideClick);
491
615
  }
492
- build(speeds) {
616
+ build(speeds, captionLabels) {
493
617
  if (this.config.playPause) {
494
618
  this.playBtn = this.button("kzp-play", "Play", ICONS.play);
495
619
  this.playBtn.addEventListener("click", () => this.cb.onToggle());
@@ -566,16 +690,72 @@ var Controls = class {
566
690
  }
567
691
  this.speedBtn.addEventListener("click", () => {
568
692
  this.speedMenu.hidden = !this.speedMenu.hidden;
693
+ if (this.ccMenu) this.ccMenu.hidden = true;
569
694
  });
570
695
  wrap.append(this.speedBtn, this.speedMenu);
571
696
  this.el.appendChild(wrap);
572
697
  }
698
+ if (this.config.captions && captionLabels.length) {
699
+ this.buildCaptions(captionLabels);
700
+ }
573
701
  if (this.config.fullscreen) {
574
702
  const btn = this.button("kzp-full", "Fullscreen", ICONS.full);
575
703
  btn.addEventListener("click", () => this.cb.onFullscreen());
576
704
  this.el.appendChild(btn);
577
705
  }
578
706
  }
707
+ /**
708
+ * One track: the CC button toggles it. Several: it opens a menu (Off + each
709
+ * track), styled like the speed menu. Sits left of the speed control.
710
+ */
711
+ buildCaptions(labels) {
712
+ const wrap = document.createElement("div");
713
+ wrap.className = "kzp-cc";
714
+ this.ccBtn = this.button("kzp-cc-btn", "Subtitles/closed captions", ICONS.captions);
715
+ this.ccBtn.setAttribute("aria-pressed", "false");
716
+ wrap.appendChild(this.ccBtn);
717
+ if (labels.length === 1) {
718
+ this.ccBtn.addEventListener("click", () => this.cb.onCaptions(this.ccActive === -1 ? 0 : -1));
719
+ } else {
720
+ this.ccMenu = document.createElement("div");
721
+ this.ccMenu.className = "kzp-speed-menu kzp-cc-menu";
722
+ this.ccMenu.hidden = true;
723
+ const items = [{ index: -1, label: "Off" }, ...labels.map((label, index) => ({ index, label }))];
724
+ for (const { index, label } of items) {
725
+ const item = document.createElement("button");
726
+ item.type = "button";
727
+ item.textContent = label;
728
+ item.dataset.track = String(index);
729
+ item.addEventListener("click", () => {
730
+ this.cb.onCaptions(index);
731
+ this.ccMenu.hidden = true;
732
+ });
733
+ this.ccMenu.appendChild(item);
734
+ }
735
+ this.ccBtn.addEventListener("click", () => {
736
+ this.ccMenu.hidden = !this.ccMenu.hidden;
737
+ if (this.speedMenu) this.speedMenu.hidden = true;
738
+ });
739
+ wrap.appendChild(this.ccMenu);
740
+ }
741
+ this.ccWrap = wrap;
742
+ const speed = this.el.querySelector(".kzp-speed");
743
+ this.el.insertBefore(wrap, speed);
744
+ }
745
+ /** Reflect the active caption track, and hide tracks (or the whole control) that failed to load. */
746
+ setCaptions(active, available) {
747
+ var _a;
748
+ this.ccActive = active;
749
+ if (!this.ccBtn || !this.ccWrap) return;
750
+ this.ccWrap.hidden = !available.some(Boolean);
751
+ this.ccBtn.classList.toggle("kzp-cc-btn--on", active !== -1);
752
+ this.ccBtn.setAttribute("aria-pressed", String(active !== -1));
753
+ (_a = this.ccMenu) == null ? void 0 : _a.querySelectorAll("button").forEach((item) => {
754
+ const index = Number(item.dataset.track);
755
+ item.hidden = index !== -1 && !available[index];
756
+ item.classList.toggle("kzp-menu-item--active", index === active);
757
+ });
758
+ }
579
759
  button(cls, label, svg) {
580
760
  const b = document.createElement("button");
581
761
  b.type = "button";
@@ -592,9 +772,14 @@ var Controls = class {
592
772
  }
593
773
  /** Push the latest state into the DOM. Called on every player timeupdate. */
594
774
  update(state, extras) {
775
+ var _a;
595
776
  if (this.playBtn) {
596
- this.playBtn.innerHTML = icon(state.playing ? ICONS.pause : ICONS.play);
597
- this.playBtn.setAttribute("aria-label", state.playing ? "Pause" : "Play");
777
+ const next = state.playing ? ICONS.pause : ICONS.play;
778
+ if (next !== this.playIcon) {
779
+ this.playIcon = next;
780
+ this.playBtn.innerHTML = icon(next);
781
+ this.playBtn.setAttribute("aria-label", state.playing ? "Pause" : "Play");
782
+ }
598
783
  }
599
784
  if (this.scrubber) {
600
785
  this.seekableMax = this.lockSeek ? state.furthest : Infinity;
@@ -605,14 +790,25 @@ var Controls = class {
605
790
  this.timeEl.textContent = `${fmt(state.current)} / ${fmt(state.duration)}`;
606
791
  }
607
792
  if (this.volBtn) {
608
- this.volBtn.innerHTML = icon(extras.muted || extras.volume === 0 ? ICONS.muted : ICONS.volume);
793
+ const next = extras.muted || extras.volume === 0 ? ICONS.muted : ICONS.volume;
794
+ if (next !== this.volIcon) {
795
+ this.volIcon = next;
796
+ this.volBtn.innerHTML = icon(next);
797
+ }
609
798
  }
610
799
  if (this.volRange && document.activeElement !== this.volRange) {
611
800
  this.volRange.value = String(extras.muted ? 0 : extras.volume);
612
801
  }
613
- if (this.speedBtn) this.speedBtn.textContent = `${extras.rate}x`;
802
+ if (this.speedBtn && extras.rate !== this.rate) {
803
+ this.rate = extras.rate;
804
+ this.speedBtn.textContent = `${extras.rate}x`;
805
+ (_a = this.speedMenu) == null ? void 0 : _a.querySelectorAll("button").forEach((item) => {
806
+ item.classList.toggle("kzp-menu-item--active", Number(item.dataset.rate) === extras.rate);
807
+ });
808
+ }
614
809
  }
615
810
  destroy() {
811
+ document.removeEventListener("click", this.onOutsideClick);
616
812
  this.el.remove();
617
813
  }
618
814
  };
@@ -625,13 +821,14 @@ var Player = class {
625
821
  this.container = container;
626
822
  this.options = options;
627
823
  this.emitter = new Emitter();
824
+ this.captions = null;
628
825
  this.furthest = 0;
629
826
  this.lastTick = 0;
630
827
  this.duration = 0;
631
828
  this.playing = false;
632
829
  this.ready = false;
633
830
  this.disposers = [];
634
- var _a, _b, _c, _d, _e, _f, _g;
831
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
635
832
  const speeds = (_a = options.speeds) != null ? _a : DEFAULT_SPEEDS;
636
833
  this.root = document.createElement("div");
637
834
  this.root.className = "kzp" + (options.className ? ` ${options.className}` : "");
@@ -646,6 +843,10 @@ var Player = class {
646
843
  this.stage.appendChild(hitLayer);
647
844
  this.provider = createProvider(options.source);
648
845
  const caps = this.provider.capabilities;
846
+ if ((_b = options.tracks) == null ? void 0 : _b.length) {
847
+ this.captions = new Captions(options.tracks, () => this.syncCaptions());
848
+ this.root.appendChild(this.captions.el);
849
+ }
649
850
  this.controls = new Controls(
650
851
  {
651
852
  onToggle: () => this.toggle(),
@@ -663,17 +864,22 @@ var Player = class {
663
864
  this.provider.setRate(r);
664
865
  this.render();
665
866
  },
867
+ onCaptions: (index) => {
868
+ var _a2;
869
+ return (_a2 = this.captions) == null ? void 0 : _a2.select(index);
870
+ },
666
871
  onFullscreen: () => this.toggleFullscreen()
667
872
  },
668
873
  {
669
874
  config: {
670
875
  ...options.controls,
671
- seek: ((_c = (_b = options.controls) == null ? void 0 : _b.seek) != null ? _c : true) && caps.seekable,
672
- speed: ((_e = (_d = options.controls) == null ? void 0 : _d.speed) != null ? _e : true) && caps.rate,
673
- volume: ((_g = (_f = options.controls) == null ? void 0 : _f.volume) != null ? _g : true) && caps.volume
876
+ seek: ((_d = (_c = options.controls) == null ? void 0 : _c.seek) != null ? _d : true) && caps.seekable,
877
+ speed: ((_f = (_e = options.controls) == null ? void 0 : _e.speed) != null ? _f : true) && caps.rate,
878
+ volume: ((_h = (_g = options.controls) == null ? void 0 : _g.volume) != null ? _h : true) && caps.volume
674
879
  },
675
880
  speeds,
676
- lockSeek: !!options.lockSeek
881
+ lockSeek: !!options.lockSeek,
882
+ captionLabels: (_i = this.captions) == null ? void 0 : _i.labels()
677
883
  }
678
884
  );
679
885
  this.root.appendChild(this.controls.el);
@@ -726,6 +932,7 @@ var Player = class {
726
932
  this.render();
727
933
  }
728
934
  onTimeUpdate(current) {
935
+ var _a;
729
936
  const advance = current - this.lastTick;
730
937
  if (advance > 0 && advance <= NATURAL_ADVANCE_MAX && current > this.furthest) {
731
938
  this.furthest = current;
@@ -733,9 +940,14 @@ var Player = class {
733
940
  this.provider.seek(this.furthest);
734
941
  }
735
942
  this.lastTick = current;
943
+ (_a = this.captions) == null ? void 0 : _a.update(current);
736
944
  this.emitter.emit("timeupdate", this.snapshot());
737
945
  this.render();
738
946
  }
947
+ syncCaptions() {
948
+ if (!this.captions) return;
949
+ this.controls.setCaptions(this.captions.getActive(), this.captions.available());
950
+ }
739
951
  snapshot() {
740
952
  const current = this.provider.getCurrentTime();
741
953
  const duration = this.duration || this.provider.getDuration();
@@ -758,21 +970,32 @@ var Player = class {
758
970
  }
759
971
  }
760
972
  // ---- Public API ----
973
+ // play()/pause() record the intent at once instead of waiting for the
974
+ // provider to confirm: YouTube only reports PLAYING after buffering, and
975
+ // until then a second click read "not playing" and called play() again
976
+ // (a no-op) instead of pausing. The provider's play/pause/ended events
977
+ // still correct it, e.g. when the browser blocks playback.
761
978
  play() {
979
+ this.playing = true;
762
980
  this.provider.play();
981
+ this.render();
763
982
  }
764
983
  pause() {
984
+ this.playing = false;
765
985
  this.provider.pause();
986
+ this.render();
766
987
  }
767
988
  toggle() {
768
- if (this.provider.isPlaying()) this.provider.pause();
769
- else this.provider.play();
989
+ if (this.playing) this.pause();
990
+ else this.play();
770
991
  }
771
992
  seek(seconds) {
993
+ var _a;
772
994
  if (!this.provider.capabilities.seekable) return;
773
995
  const target = this.options.lockSeek && seconds > this.furthest ? this.furthest : Math.max(0, seconds);
774
996
  this.provider.seek(target);
775
997
  this.lastTick = target;
998
+ (_a = this.captions) == null ? void 0 : _a.update(target);
776
999
  this.render();
777
1000
  }
778
1001
  setLockSeek(locked) {
@@ -791,9 +1014,11 @@ var Player = class {
791
1014
  this.emitter.off(event, cb);
792
1015
  }
793
1016
  destroy() {
1017
+ var _a;
794
1018
  for (const dispose of this.disposers) dispose();
795
1019
  this.disposers = [];
796
1020
  this.provider.destroy();
1021
+ (_a = this.captions) == null ? void 0 : _a.destroy();
797
1022
  this.controls.destroy();
798
1023
  this.emitter.clear();
799
1024
  this.root.remove();
@@ -806,7 +1031,7 @@ function createPlayer(container, options) {
806
1031
  }
807
1032
  var KodezenPlayer = forwardRef(
808
1033
  function KodezenPlayer2(props, ref) {
809
- var _a, _b;
1034
+ var _a, _b, _c;
810
1035
  const hostRef = useRef(null);
811
1036
  const playerRef = useRef(null);
812
1037
  const propsRef = useRef(props);
@@ -856,7 +1081,8 @@ var KodezenPlayer = forwardRef(
856
1081
  return (_a2 = playerRef.current) == null ? void 0 : _a2.destroy();
857
1082
  }
858
1083
  }));
859
- const sourceKey = `${props.source.type}|${(_a = props.source.url) != null ? _a : ""}|${(_b = props.source.id) != null ? _b : ""}`;
1084
+ const trackKey = ((_a = props.tracks) != null ? _a : []).map((t) => t.src).join(",");
1085
+ const sourceKey = `${props.source.type}|${(_b = props.source.url) != null ? _b : ""}|${(_c = props.source.id) != null ? _c : ""}|${trackKey}`;
860
1086
  useEffect(() => {
861
1087
  if (!hostRef.current) return;
862
1088
  const p = propsRef.current;
@@ -868,7 +1094,8 @@ var KodezenPlayer = forwardRef(
868
1094
  muted: p.muted,
869
1095
  speeds: p.speeds,
870
1096
  controls: p.controls,
871
- className: p.className
1097
+ className: p.className,
1098
+ tracks: p.tracks
872
1099
  });
873
1100
  playerRef.current = player;
874
1101
  const offs = [