@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.
@@ -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,16 +795,18 @@ 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}` : "");
809
+ this.root.classList.add(`kzp--${options.source.type}`);
638
810
  if (options.lockSeek) this.root.classList.add("kzp--locked");
639
811
  this.stage = document.createElement("div");
640
812
  this.stage.className = "kzp-stage";
@@ -645,6 +817,10 @@ var Player = class {
645
817
  this.stage.appendChild(hitLayer);
646
818
  this.provider = createProvider(options.source);
647
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
+ }
648
824
  this.controls = new Controls(
649
825
  {
650
826
  onToggle: () => this.toggle(),
@@ -662,17 +838,22 @@ var Player = class {
662
838
  this.provider.setRate(r);
663
839
  this.render();
664
840
  },
841
+ onCaptions: (index) => {
842
+ var _a2;
843
+ return (_a2 = this.captions) == null ? void 0 : _a2.select(index);
844
+ },
665
845
  onFullscreen: () => this.toggleFullscreen()
666
846
  },
667
847
  {
668
848
  config: {
669
849
  ...options.controls,
670
- seek: ((_c = (_b = options.controls) == null ? void 0 : _b.seek) != null ? _c : true) && caps.seekable,
671
- speed: ((_e = (_d = options.controls) == null ? void 0 : _d.speed) != null ? _e : true) && caps.rate,
672
- 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
673
853
  },
674
854
  speeds,
675
- lockSeek: !!options.lockSeek
855
+ lockSeek: !!options.lockSeek,
856
+ captionLabels: (_i = this.captions) == null ? void 0 : _i.labels()
676
857
  }
677
858
  );
678
859
  this.root.appendChild(this.controls.el);
@@ -725,6 +906,7 @@ var Player = class {
725
906
  this.render();
726
907
  }
727
908
  onTimeUpdate(current) {
909
+ var _a;
728
910
  const advance = current - this.lastTick;
729
911
  if (advance > 0 && advance <= NATURAL_ADVANCE_MAX && current > this.furthest) {
730
912
  this.furthest = current;
@@ -732,9 +914,14 @@ var Player = class {
732
914
  this.provider.seek(this.furthest);
733
915
  }
734
916
  this.lastTick = current;
917
+ (_a = this.captions) == null ? void 0 : _a.update(current);
735
918
  this.emitter.emit("timeupdate", this.snapshot());
736
919
  this.render();
737
920
  }
921
+ syncCaptions() {
922
+ if (!this.captions) return;
923
+ this.controls.setCaptions(this.captions.getActive(), this.captions.available());
924
+ }
738
925
  snapshot() {
739
926
  const current = this.provider.getCurrentTime();
740
927
  const duration = this.duration || this.provider.getDuration();
@@ -768,10 +955,12 @@ var Player = class {
768
955
  else this.provider.play();
769
956
  }
770
957
  seek(seconds) {
958
+ var _a;
771
959
  if (!this.provider.capabilities.seekable) return;
772
960
  const target = this.options.lockSeek && seconds > this.furthest ? this.furthest : Math.max(0, seconds);
773
961
  this.provider.seek(target);
774
962
  this.lastTick = target;
963
+ (_a = this.captions) == null ? void 0 : _a.update(target);
775
964
  this.render();
776
965
  }
777
966
  setLockSeek(locked) {
@@ -790,9 +979,11 @@ var Player = class {
790
979
  this.emitter.off(event, cb);
791
980
  }
792
981
  destroy() {
982
+ var _a;
793
983
  for (const dispose of this.disposers) dispose();
794
984
  this.disposers = [];
795
985
  this.provider.destroy();
986
+ (_a = this.captions) == null ? void 0 : _a.destroy();
796
987
  this.controls.destroy();
797
988
  this.emitter.clear();
798
989
  this.root.remove();
@@ -805,7 +996,7 @@ function createPlayer(container, options) {
805
996
  }
806
997
  var KodezenPlayer = forwardRef(
807
998
  function KodezenPlayer2(props, ref) {
808
- var _a, _b;
999
+ var _a, _b, _c;
809
1000
  const hostRef = useRef(null);
810
1001
  const playerRef = useRef(null);
811
1002
  const propsRef = useRef(props);
@@ -855,7 +1046,8 @@ var KodezenPlayer = forwardRef(
855
1046
  return (_a2 = playerRef.current) == null ? void 0 : _a2.destroy();
856
1047
  }
857
1048
  }));
858
- 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}`;
859
1051
  useEffect(() => {
860
1052
  if (!hostRef.current) return;
861
1053
  const p = propsRef.current;
@@ -867,7 +1059,8 @@ var KodezenPlayer = forwardRef(
867
1059
  muted: p.muted,
868
1060
  speeds: p.speeds,
869
1061
  controls: p.controls,
870
- className: p.className
1062
+ className: p.className,
1063
+ tracks: p.tracks
871
1064
  });
872
1065
  playerRef.current = player;
873
1066
  const offs = [