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