@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.
@@ -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,13 +797,14 @@ 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}` : "");
@@ -648,6 +819,10 @@ var Player = class {
648
819
  this.stage.appendChild(hitLayer);
649
820
  this.provider = createProvider(options.source);
650
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
+ }
651
826
  this.controls = new Controls(
652
827
  {
653
828
  onToggle: () => this.toggle(),
@@ -665,17 +840,22 @@ var Player = class {
665
840
  this.provider.setRate(r);
666
841
  this.render();
667
842
  },
843
+ onCaptions: (index) => {
844
+ var _a2;
845
+ return (_a2 = this.captions) == null ? void 0 : _a2.select(index);
846
+ },
668
847
  onFullscreen: () => this.toggleFullscreen()
669
848
  },
670
849
  {
671
850
  config: {
672
851
  ...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
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
676
855
  },
677
856
  speeds,
678
- lockSeek: !!options.lockSeek
857
+ lockSeek: !!options.lockSeek,
858
+ captionLabels: (_i = this.captions) == null ? void 0 : _i.labels()
679
859
  }
680
860
  );
681
861
  this.root.appendChild(this.controls.el);
@@ -728,6 +908,7 @@ var Player = class {
728
908
  this.render();
729
909
  }
730
910
  onTimeUpdate(current) {
911
+ var _a;
731
912
  const advance = current - this.lastTick;
732
913
  if (advance > 0 && advance <= NATURAL_ADVANCE_MAX && current > this.furthest) {
733
914
  this.furthest = current;
@@ -735,9 +916,14 @@ var Player = class {
735
916
  this.provider.seek(this.furthest);
736
917
  }
737
918
  this.lastTick = current;
919
+ (_a = this.captions) == null ? void 0 : _a.update(current);
738
920
  this.emitter.emit("timeupdate", this.snapshot());
739
921
  this.render();
740
922
  }
923
+ syncCaptions() {
924
+ if (!this.captions) return;
925
+ this.controls.setCaptions(this.captions.getActive(), this.captions.available());
926
+ }
741
927
  snapshot() {
742
928
  const current = this.provider.getCurrentTime();
743
929
  const duration = this.duration || this.provider.getDuration();
@@ -771,10 +957,12 @@ var Player = class {
771
957
  else this.provider.play();
772
958
  }
773
959
  seek(seconds) {
960
+ var _a;
774
961
  if (!this.provider.capabilities.seekable) return;
775
962
  const target = this.options.lockSeek && seconds > this.furthest ? this.furthest : Math.max(0, seconds);
776
963
  this.provider.seek(target);
777
964
  this.lastTick = target;
965
+ (_a = this.captions) == null ? void 0 : _a.update(target);
778
966
  this.render();
779
967
  }
780
968
  setLockSeek(locked) {
@@ -793,9 +981,11 @@ var Player = class {
793
981
  this.emitter.off(event, cb);
794
982
  }
795
983
  destroy() {
984
+ var _a;
796
985
  for (const dispose of this.disposers) dispose();
797
986
  this.disposers = [];
798
987
  this.provider.destroy();
988
+ (_a = this.captions) == null ? void 0 : _a.destroy();
799
989
  this.controls.destroy();
800
990
  this.emitter.clear();
801
991
  this.root.remove();
@@ -808,7 +998,7 @@ function createPlayer(container, options) {
808
998
  }
809
999
  var KodezenPlayer = react.forwardRef(
810
1000
  function KodezenPlayer2(props, ref) {
811
- var _a, _b;
1001
+ var _a, _b, _c;
812
1002
  const hostRef = react.useRef(null);
813
1003
  const playerRef = react.useRef(null);
814
1004
  const propsRef = react.useRef(props);
@@ -858,7 +1048,8 @@ var KodezenPlayer = react.forwardRef(
858
1048
  return (_a2 = playerRef.current) == null ? void 0 : _a2.destroy();
859
1049
  }
860
1050
  }));
861
- 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}`;
862
1053
  react.useEffect(() => {
863
1054
  if (!hostRef.current) return;
864
1055
  const p = propsRef.current;
@@ -870,7 +1061,8 @@ var KodezenPlayer = react.forwardRef(
870
1061
  muted: p.muted,
871
1062
  speeds: p.speeds,
872
1063
  controls: p.controls,
873
- className: p.className
1064
+ className: p.className,
1065
+ tracks: p.tracks
874
1066
  });
875
1067
  playerRef.current = player;
876
1068
  const offs = [