@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.
package/dist/index.js CHANGED
@@ -445,13 +445,124 @@ function createProvider(source) {
445
445
  }
446
446
  }
447
447
 
448
+ // src/core/Captions.ts
449
+ var TIMING = /^\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s+-->\s+((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})/;
450
+ var ENTITIES = {
451
+ amp: "&",
452
+ lt: "<",
453
+ gt: ">",
454
+ quot: '"',
455
+ apos: "'",
456
+ nbsp: "\xA0",
457
+ lrm: "\u200E",
458
+ rlm: "\u200F"
459
+ };
460
+ function toSeconds(stamp) {
461
+ const [clock = "", fraction = "0"] = stamp.replace(",", ".").split(".");
462
+ const seconds = clock.split(":").reduce((total, part) => total * 60 + Number(part), 0);
463
+ return seconds + Number(`0.${fraction}`);
464
+ }
465
+ function plainText(text) {
466
+ return text.replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|apos|nbsp|lrm|rlm);/g, (_, name) => {
467
+ var _a;
468
+ return (_a = ENTITIES[name]) != null ? _a : "";
469
+ }).replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
470
+ }
471
+ function parseVtt(input) {
472
+ var _a;
473
+ const cues = [];
474
+ const blocks = input.replace(/^/, "").replace(/\r\n?/g, "\n").split(/\n{2,}/);
475
+ for (const block of blocks) {
476
+ const lines = block.split("\n");
477
+ const timingAt = lines.findIndex((line) => line.includes("-->"));
478
+ const timing = TIMING.exec((_a = lines[timingAt]) != null ? _a : "");
479
+ if (!(timing == null ? void 0 : timing[1]) || !timing[2]) continue;
480
+ const text = plainText(lines.slice(timingAt + 1).join("\n")).trim();
481
+ if (!text) continue;
482
+ cues.push({ start: toSeconds(timing[1]), end: toSeconds(timing[2]), text });
483
+ }
484
+ return cues.sort((a, b) => a.start - b.start);
485
+ }
486
+ var Captions = class {
487
+ /** `onChange` fires when the active track or track availability changes. */
488
+ constructor(tracks, onChange) {
489
+ this.onChange = onChange;
490
+ this.active = -1;
491
+ this.time = 0;
492
+ this.abort = new AbortController();
493
+ this.el = document.createElement("div");
494
+ this.el.className = "kzp-captions";
495
+ this.el.setAttribute("aria-live", "off");
496
+ this.text = document.createElement("span");
497
+ this.text.className = "kzp-captions-text";
498
+ this.text.hidden = true;
499
+ this.el.appendChild(this.text);
500
+ this.tracks = tracks.map((info) => ({ info, cues: null, failed: false }));
501
+ const preferred = tracks.findIndex((t) => t.default);
502
+ this.tracks.forEach((track, index) => {
503
+ void this.load(track).then(() => {
504
+ if (index === preferred && this.active === -1 && track.cues) this.select(index);
505
+ else this.onChange();
506
+ });
507
+ });
508
+ }
509
+ async load(track) {
510
+ try {
511
+ const res = await fetch(track.info.src, { signal: this.abort.signal, credentials: "same-origin" });
512
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
513
+ track.cues = parseVtt(await res.text());
514
+ } catch (err) {
515
+ if (this.abort.signal.aborted) return;
516
+ track.failed = true;
517
+ console.warn(`@kodezen/player: could not load captions from ${track.info.src}`, err);
518
+ }
519
+ }
520
+ /** Labels for the menu, one per track (falls back to the language, then a number). */
521
+ labels() {
522
+ return this.tracks.map((t, i) => t.info.label || t.info.srclang || `Track ${i + 1}`);
523
+ }
524
+ /** Whether each track loaded (a failed track is hidden from the controls). */
525
+ available() {
526
+ return this.tracks.map((t) => !t.failed);
527
+ }
528
+ getActive() {
529
+ return this.active;
530
+ }
531
+ /** Show track `index`, or turn captions off with -1. */
532
+ select(index) {
533
+ var _a;
534
+ const track = this.tracks[index];
535
+ this.active = track && !track.failed ? index : -1;
536
+ this.el.lang = (_a = track == null ? void 0 : track.info.srclang) != null ? _a : "";
537
+ this.render();
538
+ this.onChange();
539
+ }
540
+ /** Called with the playhead position on every tick and after a seek. */
541
+ update(time) {
542
+ this.time = time;
543
+ this.render();
544
+ }
545
+ render() {
546
+ var _a, _b;
547
+ const cues = (_b = (_a = this.tracks[this.active]) == null ? void 0 : _a.cues) != null ? _b : [];
548
+ const text = cues.filter((cue) => this.time >= cue.start && this.time < cue.end).map((cue) => cue.text).join("\n");
549
+ if (text !== this.text.textContent) this.text.textContent = text;
550
+ this.text.hidden = !text;
551
+ }
552
+ destroy() {
553
+ this.abort.abort();
554
+ this.el.remove();
555
+ }
556
+ };
557
+
448
558
  // src/core/Controls.ts
449
559
  var ICONS = {
450
560
  play: '<path d="M8 5v14l11-7z"/>',
451
561
  pause: '<path d="M6 5h4v14H6zM14 5h4v14h-4z"/>',
452
562
  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"/>',
453
563
  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"/>',
454
- full: '<path d="M4 4h6v2H6v4H4V4zm10 0h6v6h-2V6h-4V4zM4 14h2v4h4v2H4v-6zm14 0h2v6h-6v-2h4v-4z"/>'
564
+ full: '<path d="M4 4h6v2H6v4H4V4zm10 0h6v6h-2V6h-4V4zM4 14h2v4h4v2H4v-6zm14 0h2v6h-6v-2h4v-4z"/>',
565
+ 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"/>'
455
566
  };
456
567
  function icon(paths) {
457
568
  return `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
@@ -471,20 +582,23 @@ var DEFAULT_CONFIG = {
471
582
  time: true,
472
583
  volume: true,
473
584
  speed: true,
585
+ captions: true,
474
586
  fullscreen: true
475
587
  };
476
588
  var Controls = class {
477
589
  constructor(cb, opts) {
478
590
  this.cb = cb;
591
+ this.ccActive = -1;
479
592
  this.scrubbing = false;
480
593
  this.seekableMax = Infinity;
594
+ var _a;
481
595
  this.config = { ...DEFAULT_CONFIG, ...opts.config };
482
596
  this.lockSeek = opts.lockSeek;
483
597
  this.el = document.createElement("div");
484
598
  this.el.className = "kzp-controls";
485
- this.build(opts.speeds);
599
+ this.build(opts.speeds, (_a = opts.captionLabels) != null ? _a : []);
486
600
  }
487
- build(speeds) {
601
+ build(speeds, captionLabels) {
488
602
  if (this.config.playPause) {
489
603
  this.playBtn = this.button("kzp-play", "Play", ICONS.play);
490
604
  this.playBtn.addEventListener("click", () => this.cb.onToggle());
@@ -561,16 +675,72 @@ var Controls = class {
561
675
  }
562
676
  this.speedBtn.addEventListener("click", () => {
563
677
  this.speedMenu.hidden = !this.speedMenu.hidden;
678
+ if (this.ccMenu) this.ccMenu.hidden = true;
564
679
  });
565
680
  wrap.append(this.speedBtn, this.speedMenu);
566
681
  this.el.appendChild(wrap);
567
682
  }
683
+ if (this.config.captions && captionLabels.length) {
684
+ this.buildCaptions(captionLabels);
685
+ }
568
686
  if (this.config.fullscreen) {
569
687
  const btn = this.button("kzp-full", "Fullscreen", ICONS.full);
570
688
  btn.addEventListener("click", () => this.cb.onFullscreen());
571
689
  this.el.appendChild(btn);
572
690
  }
573
691
  }
692
+ /**
693
+ * One track: the CC button toggles it. Several: it opens a menu (Off + each
694
+ * track), styled like the speed menu. Sits left of the speed control.
695
+ */
696
+ buildCaptions(labels) {
697
+ const wrap = document.createElement("div");
698
+ wrap.className = "kzp-cc";
699
+ this.ccBtn = this.button("kzp-cc-btn", "Subtitles/closed captions", ICONS.captions);
700
+ this.ccBtn.setAttribute("aria-pressed", "false");
701
+ wrap.appendChild(this.ccBtn);
702
+ if (labels.length === 1) {
703
+ this.ccBtn.addEventListener("click", () => this.cb.onCaptions(this.ccActive === -1 ? 0 : -1));
704
+ } else {
705
+ this.ccMenu = document.createElement("div");
706
+ this.ccMenu.className = "kzp-speed-menu kzp-cc-menu";
707
+ this.ccMenu.hidden = true;
708
+ const items = [{ index: -1, label: "Off" }, ...labels.map((label, index) => ({ index, label }))];
709
+ for (const { index, label } of items) {
710
+ const item = document.createElement("button");
711
+ item.type = "button";
712
+ item.textContent = label;
713
+ item.dataset.track = String(index);
714
+ item.addEventListener("click", () => {
715
+ this.cb.onCaptions(index);
716
+ this.ccMenu.hidden = true;
717
+ });
718
+ this.ccMenu.appendChild(item);
719
+ }
720
+ this.ccBtn.addEventListener("click", () => {
721
+ this.ccMenu.hidden = !this.ccMenu.hidden;
722
+ if (this.speedMenu) this.speedMenu.hidden = true;
723
+ });
724
+ wrap.appendChild(this.ccMenu);
725
+ }
726
+ this.ccWrap = wrap;
727
+ const speed = this.el.querySelector(".kzp-speed");
728
+ this.el.insertBefore(wrap, speed);
729
+ }
730
+ /** Reflect the active caption track, and hide tracks (or the whole control) that failed to load. */
731
+ setCaptions(active, available) {
732
+ var _a;
733
+ this.ccActive = active;
734
+ if (!this.ccBtn || !this.ccWrap) return;
735
+ this.ccWrap.hidden = !available.some(Boolean);
736
+ this.ccBtn.classList.toggle("kzp-cc-btn--on", active !== -1);
737
+ this.ccBtn.setAttribute("aria-pressed", String(active !== -1));
738
+ (_a = this.ccMenu) == null ? void 0 : _a.querySelectorAll("button").forEach((item) => {
739
+ const index = Number(item.dataset.track);
740
+ item.hidden = index !== -1 && !available[index];
741
+ item.classList.toggle("kzp-menu-item--active", index === active);
742
+ });
743
+ }
574
744
  button(cls, label, svg) {
575
745
  const b = document.createElement("button");
576
746
  b.type = "button";
@@ -620,13 +790,14 @@ var Player = class {
620
790
  this.container = container;
621
791
  this.options = options;
622
792
  this.emitter = new Emitter();
793
+ this.captions = null;
623
794
  this.furthest = 0;
624
795
  this.lastTick = 0;
625
796
  this.duration = 0;
626
797
  this.playing = false;
627
798
  this.ready = false;
628
799
  this.disposers = [];
629
- var _a, _b, _c, _d, _e, _f, _g;
800
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
630
801
  const speeds = (_a = options.speeds) != null ? _a : DEFAULT_SPEEDS;
631
802
  this.root = document.createElement("div");
632
803
  this.root.className = "kzp" + (options.className ? ` ${options.className}` : "");
@@ -641,6 +812,10 @@ var Player = class {
641
812
  this.stage.appendChild(hitLayer);
642
813
  this.provider = createProvider(options.source);
643
814
  const caps = this.provider.capabilities;
815
+ if ((_b = options.tracks) == null ? void 0 : _b.length) {
816
+ this.captions = new Captions(options.tracks, () => this.syncCaptions());
817
+ this.root.appendChild(this.captions.el);
818
+ }
644
819
  this.controls = new Controls(
645
820
  {
646
821
  onToggle: () => this.toggle(),
@@ -658,17 +833,22 @@ var Player = class {
658
833
  this.provider.setRate(r);
659
834
  this.render();
660
835
  },
836
+ onCaptions: (index) => {
837
+ var _a2;
838
+ return (_a2 = this.captions) == null ? void 0 : _a2.select(index);
839
+ },
661
840
  onFullscreen: () => this.toggleFullscreen()
662
841
  },
663
842
  {
664
843
  config: {
665
844
  ...options.controls,
666
- seek: ((_c = (_b = options.controls) == null ? void 0 : _b.seek) != null ? _c : true) && caps.seekable,
667
- speed: ((_e = (_d = options.controls) == null ? void 0 : _d.speed) != null ? _e : true) && caps.rate,
668
- volume: ((_g = (_f = options.controls) == null ? void 0 : _f.volume) != null ? _g : true) && caps.volume
845
+ seek: ((_d = (_c = options.controls) == null ? void 0 : _c.seek) != null ? _d : true) && caps.seekable,
846
+ speed: ((_f = (_e = options.controls) == null ? void 0 : _e.speed) != null ? _f : true) && caps.rate,
847
+ volume: ((_h = (_g = options.controls) == null ? void 0 : _g.volume) != null ? _h : true) && caps.volume
669
848
  },
670
849
  speeds,
671
- lockSeek: !!options.lockSeek
850
+ lockSeek: !!options.lockSeek,
851
+ captionLabels: (_i = this.captions) == null ? void 0 : _i.labels()
672
852
  }
673
853
  );
674
854
  this.root.appendChild(this.controls.el);
@@ -721,6 +901,7 @@ var Player = class {
721
901
  this.render();
722
902
  }
723
903
  onTimeUpdate(current) {
904
+ var _a;
724
905
  const advance = current - this.lastTick;
725
906
  if (advance > 0 && advance <= NATURAL_ADVANCE_MAX && current > this.furthest) {
726
907
  this.furthest = current;
@@ -728,9 +909,14 @@ var Player = class {
728
909
  this.provider.seek(this.furthest);
729
910
  }
730
911
  this.lastTick = current;
912
+ (_a = this.captions) == null ? void 0 : _a.update(current);
731
913
  this.emitter.emit("timeupdate", this.snapshot());
732
914
  this.render();
733
915
  }
916
+ syncCaptions() {
917
+ if (!this.captions) return;
918
+ this.controls.setCaptions(this.captions.getActive(), this.captions.available());
919
+ }
734
920
  snapshot() {
735
921
  const current = this.provider.getCurrentTime();
736
922
  const duration = this.duration || this.provider.getDuration();
@@ -764,10 +950,12 @@ var Player = class {
764
950
  else this.provider.play();
765
951
  }
766
952
  seek(seconds) {
953
+ var _a;
767
954
  if (!this.provider.capabilities.seekable) return;
768
955
  const target = this.options.lockSeek && seconds > this.furthest ? this.furthest : Math.max(0, seconds);
769
956
  this.provider.seek(target);
770
957
  this.lastTick = target;
958
+ (_a = this.captions) == null ? void 0 : _a.update(target);
771
959
  this.render();
772
960
  }
773
961
  setLockSeek(locked) {
@@ -786,9 +974,11 @@ var Player = class {
786
974
  this.emitter.off(event, cb);
787
975
  }
788
976
  destroy() {
977
+ var _a;
789
978
  for (const dispose of this.disposers) dispose();
790
979
  this.disposers = [];
791
980
  this.provider.destroy();
981
+ (_a = this.captions) == null ? void 0 : _a.destroy();
792
982
  this.controls.destroy();
793
983
  this.emitter.clear();
794
984
  this.root.remove();
@@ -800,6 +990,6 @@ function createPlayer(container, options) {
800
990
  return new Player(container, options);
801
991
  }
802
992
 
803
- export { Player, createPlayer };
993
+ export { Player, createPlayer, parseVtt };
804
994
  //# sourceMappingURL=index.js.map
805
995
  //# sourceMappingURL=index.js.map