@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.
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,16 +790,18 @@ 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}` : "");
804
+ this.root.classList.add(`kzp--${options.source.type}`);
633
805
  if (options.lockSeek) this.root.classList.add("kzp--locked");
634
806
  this.stage = document.createElement("div");
635
807
  this.stage.className = "kzp-stage";
@@ -640,6 +812,10 @@ var Player = class {
640
812
  this.stage.appendChild(hitLayer);
641
813
  this.provider = createProvider(options.source);
642
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
+ }
643
819
  this.controls = new Controls(
644
820
  {
645
821
  onToggle: () => this.toggle(),
@@ -657,17 +833,22 @@ var Player = class {
657
833
  this.provider.setRate(r);
658
834
  this.render();
659
835
  },
836
+ onCaptions: (index) => {
837
+ var _a2;
838
+ return (_a2 = this.captions) == null ? void 0 : _a2.select(index);
839
+ },
660
840
  onFullscreen: () => this.toggleFullscreen()
661
841
  },
662
842
  {
663
843
  config: {
664
844
  ...options.controls,
665
- seek: ((_c = (_b = options.controls) == null ? void 0 : _b.seek) != null ? _c : true) && caps.seekable,
666
- speed: ((_e = (_d = options.controls) == null ? void 0 : _d.speed) != null ? _e : true) && caps.rate,
667
- 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
668
848
  },
669
849
  speeds,
670
- lockSeek: !!options.lockSeek
850
+ lockSeek: !!options.lockSeek,
851
+ captionLabels: (_i = this.captions) == null ? void 0 : _i.labels()
671
852
  }
672
853
  );
673
854
  this.root.appendChild(this.controls.el);
@@ -720,6 +901,7 @@ var Player = class {
720
901
  this.render();
721
902
  }
722
903
  onTimeUpdate(current) {
904
+ var _a;
723
905
  const advance = current - this.lastTick;
724
906
  if (advance > 0 && advance <= NATURAL_ADVANCE_MAX && current > this.furthest) {
725
907
  this.furthest = current;
@@ -727,9 +909,14 @@ var Player = class {
727
909
  this.provider.seek(this.furthest);
728
910
  }
729
911
  this.lastTick = current;
912
+ (_a = this.captions) == null ? void 0 : _a.update(current);
730
913
  this.emitter.emit("timeupdate", this.snapshot());
731
914
  this.render();
732
915
  }
916
+ syncCaptions() {
917
+ if (!this.captions) return;
918
+ this.controls.setCaptions(this.captions.getActive(), this.captions.available());
919
+ }
733
920
  snapshot() {
734
921
  const current = this.provider.getCurrentTime();
735
922
  const duration = this.duration || this.provider.getDuration();
@@ -763,10 +950,12 @@ var Player = class {
763
950
  else this.provider.play();
764
951
  }
765
952
  seek(seconds) {
953
+ var _a;
766
954
  if (!this.provider.capabilities.seekable) return;
767
955
  const target = this.options.lockSeek && seconds > this.furthest ? this.furthest : Math.max(0, seconds);
768
956
  this.provider.seek(target);
769
957
  this.lastTick = target;
958
+ (_a = this.captions) == null ? void 0 : _a.update(target);
770
959
  this.render();
771
960
  }
772
961
  setLockSeek(locked) {
@@ -785,9 +974,11 @@ var Player = class {
785
974
  this.emitter.off(event, cb);
786
975
  }
787
976
  destroy() {
977
+ var _a;
788
978
  for (const dispose of this.disposers) dispose();
789
979
  this.disposers = [];
790
980
  this.provider.destroy();
981
+ (_a = this.captions) == null ? void 0 : _a.destroy();
791
982
  this.controls.destroy();
792
983
  this.emitter.clear();
793
984
  this.root.remove();
@@ -799,6 +990,6 @@ function createPlayer(container, options) {
799
990
  return new Player(container, options);
800
991
  }
801
992
 
802
- export { Player, createPlayer };
993
+ export { Player, createPlayer, parseVtt };
803
994
  //# sourceMappingURL=index.js.map
804
995
  //# sourceMappingURL=index.js.map