@kodezen/player 1.0.1 → 1.0.3

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.
@@ -452,13 +452,124 @@ function createProvider(source) {
452
452
  }
453
453
  }
454
454
 
455
+ // src/core/Captions.ts
456
+ var TIMING = /^\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s+-->\s+((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})/;
457
+ var ENTITIES = {
458
+ amp: "&",
459
+ lt: "<",
460
+ gt: ">",
461
+ quot: '"',
462
+ apos: "'",
463
+ nbsp: "\xA0",
464
+ lrm: "\u200E",
465
+ rlm: "\u200F"
466
+ };
467
+ function toSeconds(stamp) {
468
+ const [clock = "", fraction = "0"] = stamp.replace(",", ".").split(".");
469
+ const seconds = clock.split(":").reduce((total, part) => total * 60 + Number(part), 0);
470
+ return seconds + Number(`0.${fraction}`);
471
+ }
472
+ function plainText(text) {
473
+ return text.replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|apos|nbsp|lrm|rlm);/g, (_, name) => {
474
+ var _a;
475
+ return (_a = ENTITIES[name]) != null ? _a : "";
476
+ }).replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
477
+ }
478
+ function parseVtt(input) {
479
+ var _a;
480
+ const cues = [];
481
+ const blocks = input.replace(/^/, "").replace(/\r\n?/g, "\n").split(/\n{2,}/);
482
+ for (const block of blocks) {
483
+ const lines = block.split("\n");
484
+ const timingAt = lines.findIndex((line) => line.includes("-->"));
485
+ const timing = TIMING.exec((_a = lines[timingAt]) != null ? _a : "");
486
+ if (!(timing == null ? void 0 : timing[1]) || !timing[2]) continue;
487
+ const text = plainText(lines.slice(timingAt + 1).join("\n")).trim();
488
+ if (!text) continue;
489
+ cues.push({ start: toSeconds(timing[1]), end: toSeconds(timing[2]), text });
490
+ }
491
+ return cues.sort((a, b) => a.start - b.start);
492
+ }
493
+ var Captions = class {
494
+ /** `onChange` fires when the active track or track availability changes. */
495
+ constructor(tracks, onChange) {
496
+ this.onChange = onChange;
497
+ this.active = -1;
498
+ this.time = 0;
499
+ this.abort = new AbortController();
500
+ this.el = document.createElement("div");
501
+ this.el.className = "kzp-captions";
502
+ this.el.setAttribute("aria-live", "off");
503
+ this.text = document.createElement("span");
504
+ this.text.className = "kzp-captions-text";
505
+ this.text.hidden = true;
506
+ this.el.appendChild(this.text);
507
+ this.tracks = tracks.map((info) => ({ info, cues: null, failed: false }));
508
+ const preferred = tracks.findIndex((t) => t.default);
509
+ this.tracks.forEach((track, index) => {
510
+ void this.load(track).then(() => {
511
+ if (index === preferred && this.active === -1 && track.cues) this.select(index);
512
+ else this.onChange();
513
+ });
514
+ });
515
+ }
516
+ async load(track) {
517
+ try {
518
+ const res = await fetch(track.info.src, { signal: this.abort.signal, credentials: "same-origin" });
519
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
520
+ track.cues = parseVtt(await res.text());
521
+ } catch (err) {
522
+ if (this.abort.signal.aborted) return;
523
+ track.failed = true;
524
+ console.warn(`@kodezen/player: could not load captions from ${track.info.src}`, err);
525
+ }
526
+ }
527
+ /** Labels for the menu, one per track (falls back to the language, then a number). */
528
+ labels() {
529
+ return this.tracks.map((t, i) => t.info.label || t.info.srclang || `Track ${i + 1}`);
530
+ }
531
+ /** Whether each track loaded (a failed track is hidden from the controls). */
532
+ available() {
533
+ return this.tracks.map((t) => !t.failed);
534
+ }
535
+ getActive() {
536
+ return this.active;
537
+ }
538
+ /** Show track `index`, or turn captions off with -1. */
539
+ select(index) {
540
+ var _a;
541
+ const track = this.tracks[index];
542
+ this.active = track && !track.failed ? index : -1;
543
+ this.el.lang = (_a = track == null ? void 0 : track.info.srclang) != null ? _a : "";
544
+ this.render();
545
+ this.onChange();
546
+ }
547
+ /** Called with the playhead position on every tick and after a seek. */
548
+ update(time) {
549
+ this.time = time;
550
+ this.render();
551
+ }
552
+ render() {
553
+ var _a, _b;
554
+ const cues = (_b = (_a = this.tracks[this.active]) == null ? void 0 : _a.cues) != null ? _b : [];
555
+ const text = cues.filter((cue) => this.time >= cue.start && this.time < cue.end).map((cue) => cue.text).join("\n");
556
+ if (text !== this.text.textContent) this.text.textContent = text;
557
+ this.text.hidden = !text;
558
+ }
559
+ destroy() {
560
+ this.abort.abort();
561
+ this.el.remove();
562
+ }
563
+ };
564
+
455
565
  // src/core/Controls.ts
456
566
  var ICONS = {
457
567
  play: '<path d="M8 5v14l11-7z"/>',
458
568
  pause: '<path d="M6 5h4v14H6zM14 5h4v14h-4z"/>',
459
569
  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
570
  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"/>'
571
+ full: '<path d="M4 4h6v2H6v4H4V4zm10 0h6v6h-2V6h-4V4zM4 14h2v4h4v2H4v-6zm14 0h2v6h-6v-2h4v-4z"/>',
572
+ 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
573
  };
463
574
  function icon(paths) {
464
575
  return `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
@@ -478,20 +589,23 @@ var DEFAULT_CONFIG = {
478
589
  time: true,
479
590
  volume: true,
480
591
  speed: true,
592
+ captions: true,
481
593
  fullscreen: true
482
594
  };
483
595
  var Controls = class {
484
596
  constructor(cb, opts) {
485
597
  this.cb = cb;
598
+ this.ccActive = -1;
486
599
  this.scrubbing = false;
487
600
  this.seekableMax = Infinity;
601
+ var _a;
488
602
  this.config = { ...DEFAULT_CONFIG, ...opts.config };
489
603
  this.lockSeek = opts.lockSeek;
490
604
  this.el = document.createElement("div");
491
605
  this.el.className = "kzp-controls";
492
- this.build(opts.speeds);
606
+ this.build(opts.speeds, (_a = opts.captionLabels) != null ? _a : []);
493
607
  }
494
- build(speeds) {
608
+ build(speeds, captionLabels) {
495
609
  if (this.config.playPause) {
496
610
  this.playBtn = this.button("kzp-play", "Play", ICONS.play);
497
611
  this.playBtn.addEventListener("click", () => this.cb.onToggle());
@@ -568,16 +682,72 @@ var Controls = class {
568
682
  }
569
683
  this.speedBtn.addEventListener("click", () => {
570
684
  this.speedMenu.hidden = !this.speedMenu.hidden;
685
+ if (this.ccMenu) this.ccMenu.hidden = true;
571
686
  });
572
687
  wrap.append(this.speedBtn, this.speedMenu);
573
688
  this.el.appendChild(wrap);
574
689
  }
690
+ if (this.config.captions && captionLabels.length) {
691
+ this.buildCaptions(captionLabels);
692
+ }
575
693
  if (this.config.fullscreen) {
576
694
  const btn = this.button("kzp-full", "Fullscreen", ICONS.full);
577
695
  btn.addEventListener("click", () => this.cb.onFullscreen());
578
696
  this.el.appendChild(btn);
579
697
  }
580
698
  }
699
+ /**
700
+ * One track: the CC button toggles it. Several: it opens a menu (Off + each
701
+ * track), styled like the speed menu. Sits left of the speed control.
702
+ */
703
+ buildCaptions(labels) {
704
+ const wrap = document.createElement("div");
705
+ wrap.className = "kzp-cc";
706
+ this.ccBtn = this.button("kzp-cc-btn", "Subtitles/closed captions", ICONS.captions);
707
+ this.ccBtn.setAttribute("aria-pressed", "false");
708
+ wrap.appendChild(this.ccBtn);
709
+ if (labels.length === 1) {
710
+ this.ccBtn.addEventListener("click", () => this.cb.onCaptions(this.ccActive === -1 ? 0 : -1));
711
+ } else {
712
+ this.ccMenu = document.createElement("div");
713
+ this.ccMenu.className = "kzp-speed-menu kzp-cc-menu";
714
+ this.ccMenu.hidden = true;
715
+ const items = [{ index: -1, label: "Off" }, ...labels.map((label, index) => ({ index, label }))];
716
+ for (const { index, label } of items) {
717
+ const item = document.createElement("button");
718
+ item.type = "button";
719
+ item.textContent = label;
720
+ item.dataset.track = String(index);
721
+ item.addEventListener("click", () => {
722
+ this.cb.onCaptions(index);
723
+ this.ccMenu.hidden = true;
724
+ });
725
+ this.ccMenu.appendChild(item);
726
+ }
727
+ this.ccBtn.addEventListener("click", () => {
728
+ this.ccMenu.hidden = !this.ccMenu.hidden;
729
+ if (this.speedMenu) this.speedMenu.hidden = true;
730
+ });
731
+ wrap.appendChild(this.ccMenu);
732
+ }
733
+ this.ccWrap = wrap;
734
+ const speed = this.el.querySelector(".kzp-speed");
735
+ this.el.insertBefore(wrap, speed);
736
+ }
737
+ /** Reflect the active caption track, and hide tracks (or the whole control) that failed to load. */
738
+ setCaptions(active, available) {
739
+ var _a;
740
+ this.ccActive = active;
741
+ if (!this.ccBtn || !this.ccWrap) return;
742
+ this.ccWrap.hidden = !available.some(Boolean);
743
+ this.ccBtn.classList.toggle("kzp-cc-btn--on", active !== -1);
744
+ this.ccBtn.setAttribute("aria-pressed", String(active !== -1));
745
+ (_a = this.ccMenu) == null ? void 0 : _a.querySelectorAll("button").forEach((item) => {
746
+ const index = Number(item.dataset.track);
747
+ item.hidden = index !== -1 && !available[index];
748
+ item.classList.toggle("kzp-menu-item--active", index === active);
749
+ });
750
+ }
581
751
  button(cls, label, svg) {
582
752
  const b = document.createElement("button");
583
753
  b.type = "button";
@@ -627,16 +797,18 @@ var Player = class {
627
797
  this.container = container;
628
798
  this.options = options;
629
799
  this.emitter = new Emitter();
800
+ this.captions = null;
630
801
  this.furthest = 0;
631
802
  this.lastTick = 0;
632
803
  this.duration = 0;
633
804
  this.playing = false;
634
805
  this.ready = false;
635
806
  this.disposers = [];
636
- var _a, _b, _c, _d, _e, _f, _g;
807
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
637
808
  const speeds = (_a = options.speeds) != null ? _a : DEFAULT_SPEEDS;
638
809
  this.root = document.createElement("div");
639
810
  this.root.className = "kzp" + (options.className ? ` ${options.className}` : "");
811
+ this.root.classList.add(`kzp--${options.source.type}`);
640
812
  if (options.lockSeek) this.root.classList.add("kzp--locked");
641
813
  this.stage = document.createElement("div");
642
814
  this.stage.className = "kzp-stage";
@@ -647,6 +819,10 @@ var Player = class {
647
819
  this.stage.appendChild(hitLayer);
648
820
  this.provider = createProvider(options.source);
649
821
  const caps = this.provider.capabilities;
822
+ if ((_b = options.tracks) == null ? void 0 : _b.length) {
823
+ this.captions = new Captions(options.tracks, () => this.syncCaptions());
824
+ this.root.appendChild(this.captions.el);
825
+ }
650
826
  this.controls = new Controls(
651
827
  {
652
828
  onToggle: () => this.toggle(),
@@ -664,17 +840,22 @@ var Player = class {
664
840
  this.provider.setRate(r);
665
841
  this.render();
666
842
  },
843
+ onCaptions: (index) => {
844
+ var _a2;
845
+ return (_a2 = this.captions) == null ? void 0 : _a2.select(index);
846
+ },
667
847
  onFullscreen: () => this.toggleFullscreen()
668
848
  },
669
849
  {
670
850
  config: {
671
851
  ...options.controls,
672
- seek: ((_c = (_b = options.controls) == null ? void 0 : _b.seek) != null ? _c : true) && caps.seekable,
673
- speed: ((_e = (_d = options.controls) == null ? void 0 : _d.speed) != null ? _e : true) && caps.rate,
674
- volume: ((_g = (_f = options.controls) == null ? void 0 : _f.volume) != null ? _g : true) && caps.volume
852
+ seek: ((_d = (_c = options.controls) == null ? void 0 : _c.seek) != null ? _d : true) && caps.seekable,
853
+ speed: ((_f = (_e = options.controls) == null ? void 0 : _e.speed) != null ? _f : true) && caps.rate,
854
+ volume: ((_h = (_g = options.controls) == null ? void 0 : _g.volume) != null ? _h : true) && caps.volume
675
855
  },
676
856
  speeds,
677
- lockSeek: !!options.lockSeek
857
+ lockSeek: !!options.lockSeek,
858
+ captionLabels: (_i = this.captions) == null ? void 0 : _i.labels()
678
859
  }
679
860
  );
680
861
  this.root.appendChild(this.controls.el);
@@ -727,6 +908,7 @@ var Player = class {
727
908
  this.render();
728
909
  }
729
910
  onTimeUpdate(current) {
911
+ var _a;
730
912
  const advance = current - this.lastTick;
731
913
  if (advance > 0 && advance <= NATURAL_ADVANCE_MAX && current > this.furthest) {
732
914
  this.furthest = current;
@@ -734,9 +916,14 @@ var Player = class {
734
916
  this.provider.seek(this.furthest);
735
917
  }
736
918
  this.lastTick = current;
919
+ (_a = this.captions) == null ? void 0 : _a.update(current);
737
920
  this.emitter.emit("timeupdate", this.snapshot());
738
921
  this.render();
739
922
  }
923
+ syncCaptions() {
924
+ if (!this.captions) return;
925
+ this.controls.setCaptions(this.captions.getActive(), this.captions.available());
926
+ }
740
927
  snapshot() {
741
928
  const current = this.provider.getCurrentTime();
742
929
  const duration = this.duration || this.provider.getDuration();
@@ -770,10 +957,12 @@ var Player = class {
770
957
  else this.provider.play();
771
958
  }
772
959
  seek(seconds) {
960
+ var _a;
773
961
  if (!this.provider.capabilities.seekable) return;
774
962
  const target = this.options.lockSeek && seconds > this.furthest ? this.furthest : Math.max(0, seconds);
775
963
  this.provider.seek(target);
776
964
  this.lastTick = target;
965
+ (_a = this.captions) == null ? void 0 : _a.update(target);
777
966
  this.render();
778
967
  }
779
968
  setLockSeek(locked) {
@@ -792,9 +981,11 @@ var Player = class {
792
981
  this.emitter.off(event, cb);
793
982
  }
794
983
  destroy() {
984
+ var _a;
795
985
  for (const dispose of this.disposers) dispose();
796
986
  this.disposers = [];
797
987
  this.provider.destroy();
988
+ (_a = this.captions) == null ? void 0 : _a.destroy();
798
989
  this.controls.destroy();
799
990
  this.emitter.clear();
800
991
  this.root.remove();
@@ -807,7 +998,7 @@ function createPlayer(container, options) {
807
998
  }
808
999
  var KodezenPlayer = react.forwardRef(
809
1000
  function KodezenPlayer2(props, ref) {
810
- var _a, _b;
1001
+ var _a, _b, _c;
811
1002
  const hostRef = react.useRef(null);
812
1003
  const playerRef = react.useRef(null);
813
1004
  const propsRef = react.useRef(props);
@@ -857,7 +1048,8 @@ var KodezenPlayer = react.forwardRef(
857
1048
  return (_a2 = playerRef.current) == null ? void 0 : _a2.destroy();
858
1049
  }
859
1050
  }));
860
- const sourceKey = `${props.source.type}|${(_a = props.source.url) != null ? _a : ""}|${(_b = props.source.id) != null ? _b : ""}`;
1051
+ const trackKey = ((_a = props.tracks) != null ? _a : []).map((t) => t.src).join(",");
1052
+ const sourceKey = `${props.source.type}|${(_b = props.source.url) != null ? _b : ""}|${(_c = props.source.id) != null ? _c : ""}|${trackKey}`;
861
1053
  react.useEffect(() => {
862
1054
  if (!hostRef.current) return;
863
1055
  const p = propsRef.current;
@@ -869,7 +1061,8 @@ var KodezenPlayer = react.forwardRef(
869
1061
  muted: p.muted,
870
1062
  speeds: p.speeds,
871
1063
  controls: p.controls,
872
- className: p.className
1064
+ className: p.className,
1065
+ tracks: p.tracks
873
1066
  });
874
1067
  playerRef.current = player;
875
1068
  const offs = [