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