@kodezen/player 1.0.2 → 1.0.4

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
@@ -91,8 +91,7 @@ var Html5Provider = class {
91
91
  }
92
92
  }
93
93
  play() {
94
- void this.video.play().catch(() => {
95
- });
94
+ void this.video.play().catch(() => this.emitter.emit("pause"));
96
95
  }
97
96
  pause() {
98
97
  this.video.pause();
@@ -374,8 +373,8 @@ var VimeoProvider = class {
374
373
  this.emitter.emit("ready");
375
374
  }
376
375
  play() {
377
- var _a, _b;
378
- void ((_b = (_a = this.player) == null ? void 0 : _a.play) == null ? void 0 : _b.call(_a));
376
+ var _a, _b, _c, _d;
377
+ (_d = (_c = (_b = (_a = this.player) == null ? void 0 : _a.play) == null ? void 0 : _b.call(_a)) == null ? void 0 : _c.catch) == null ? void 0 : _d.call(_c, () => this.emitter.emit("pause"));
379
378
  }
380
379
  pause() {
381
380
  var _a, _b;
@@ -445,13 +444,124 @@ function createProvider(source) {
445
444
  }
446
445
  }
447
446
 
447
+ // src/core/Captions.ts
448
+ var TIMING = /^\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s+-->\s+((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})/;
449
+ var ENTITIES = {
450
+ amp: "&",
451
+ lt: "<",
452
+ gt: ">",
453
+ quot: '"',
454
+ apos: "'",
455
+ nbsp: "\xA0",
456
+ lrm: "\u200E",
457
+ rlm: "\u200F"
458
+ };
459
+ function toSeconds(stamp) {
460
+ const [clock = "", fraction = "0"] = stamp.replace(",", ".").split(".");
461
+ const seconds = clock.split(":").reduce((total, part) => total * 60 + Number(part), 0);
462
+ return seconds + Number(`0.${fraction}`);
463
+ }
464
+ function plainText(text) {
465
+ return text.replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|apos|nbsp|lrm|rlm);/g, (_, name) => {
466
+ var _a;
467
+ return (_a = ENTITIES[name]) != null ? _a : "";
468
+ }).replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
469
+ }
470
+ function parseVtt(input) {
471
+ var _a;
472
+ const cues = [];
473
+ const blocks = input.replace(/^/, "").replace(/\r\n?/g, "\n").split(/\n{2,}/);
474
+ for (const block of blocks) {
475
+ const lines = block.split("\n");
476
+ const timingAt = lines.findIndex((line) => line.includes("-->"));
477
+ const timing = TIMING.exec((_a = lines[timingAt]) != null ? _a : "");
478
+ if (!(timing == null ? void 0 : timing[1]) || !timing[2]) continue;
479
+ const text = plainText(lines.slice(timingAt + 1).join("\n")).trim();
480
+ if (!text) continue;
481
+ cues.push({ start: toSeconds(timing[1]), end: toSeconds(timing[2]), text });
482
+ }
483
+ return cues.sort((a, b) => a.start - b.start);
484
+ }
485
+ var Captions = class {
486
+ /** `onChange` fires when the active track or track availability changes. */
487
+ constructor(tracks, onChange) {
488
+ this.onChange = onChange;
489
+ this.active = -1;
490
+ this.time = 0;
491
+ this.abort = new AbortController();
492
+ this.el = document.createElement("div");
493
+ this.el.className = "kzp-captions";
494
+ this.el.setAttribute("aria-live", "off");
495
+ this.text = document.createElement("span");
496
+ this.text.className = "kzp-captions-text";
497
+ this.text.hidden = true;
498
+ this.el.appendChild(this.text);
499
+ this.tracks = tracks.map((info) => ({ info, cues: null, failed: false }));
500
+ const preferred = tracks.findIndex((t) => t.default);
501
+ this.tracks.forEach((track, index) => {
502
+ void this.load(track).then(() => {
503
+ if (index === preferred && this.active === -1 && track.cues) this.select(index);
504
+ else this.onChange();
505
+ });
506
+ });
507
+ }
508
+ async load(track) {
509
+ try {
510
+ const res = await fetch(track.info.src, { signal: this.abort.signal, credentials: "same-origin" });
511
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
512
+ track.cues = parseVtt(await res.text());
513
+ } catch (err) {
514
+ if (this.abort.signal.aborted) return;
515
+ track.failed = true;
516
+ console.warn(`@kodezen/player: could not load captions from ${track.info.src}`, err);
517
+ }
518
+ }
519
+ /** Labels for the menu, one per track (falls back to the language, then a number). */
520
+ labels() {
521
+ return this.tracks.map((t, i) => t.info.label || t.info.srclang || `Track ${i + 1}`);
522
+ }
523
+ /** Whether each track loaded (a failed track is hidden from the controls). */
524
+ available() {
525
+ return this.tracks.map((t) => !t.failed);
526
+ }
527
+ getActive() {
528
+ return this.active;
529
+ }
530
+ /** Show track `index`, or turn captions off with -1. */
531
+ select(index) {
532
+ var _a;
533
+ const track = this.tracks[index];
534
+ this.active = track && !track.failed ? index : -1;
535
+ this.el.lang = (_a = track == null ? void 0 : track.info.srclang) != null ? _a : "";
536
+ this.render();
537
+ this.onChange();
538
+ }
539
+ /** Called with the playhead position on every tick and after a seek. */
540
+ update(time) {
541
+ this.time = time;
542
+ this.render();
543
+ }
544
+ render() {
545
+ var _a, _b;
546
+ const cues = (_b = (_a = this.tracks[this.active]) == null ? void 0 : _a.cues) != null ? _b : [];
547
+ const text = cues.filter((cue) => this.time >= cue.start && this.time < cue.end).map((cue) => cue.text).join("\n");
548
+ if (text !== this.text.textContent) this.text.textContent = text;
549
+ this.text.hidden = !text;
550
+ }
551
+ destroy() {
552
+ this.abort.abort();
553
+ this.el.remove();
554
+ }
555
+ };
556
+
448
557
  // src/core/Controls.ts
449
558
  var ICONS = {
450
559
  play: '<path d="M8 5v14l11-7z"/>',
451
560
  pause: '<path d="M6 5h4v14H6zM14 5h4v14h-4z"/>',
452
561
  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
562
  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"/>'
563
+ full: '<path d="M4 4h6v2H6v4H4V4zm10 0h6v6h-2V6h-4V4zM4 14h2v4h4v2H4v-6zm14 0h2v6h-6v-2h4v-4z"/>',
564
+ 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
565
  };
456
566
  function icon(paths) {
457
567
  return `<svg viewBox="0 0 24 24" aria-hidden="true">${paths}</svg>`;
@@ -471,20 +581,34 @@ var DEFAULT_CONFIG = {
471
581
  time: true,
472
582
  volume: true,
473
583
  speed: true,
584
+ captions: true,
474
585
  fullscreen: true
475
586
  };
476
587
  var Controls = class {
477
588
  constructor(cb, opts) {
478
589
  this.cb = cb;
590
+ this.ccActive = -1;
591
+ // Icons currently drawn, so update() only rewrites a button's SVG when it
592
+ // actually changes (see update()).
593
+ this.playIcon = "";
594
+ this.volIcon = "";
479
595
  this.scrubbing = false;
596
+ this.rate = 0;
597
+ this.onOutsideClick = (e) => {
598
+ if (this.el.contains(e.target)) return;
599
+ if (this.speedMenu) this.speedMenu.hidden = true;
600
+ if (this.ccMenu) this.ccMenu.hidden = true;
601
+ };
480
602
  this.seekableMax = Infinity;
603
+ var _a;
481
604
  this.config = { ...DEFAULT_CONFIG, ...opts.config };
482
605
  this.lockSeek = opts.lockSeek;
483
606
  this.el = document.createElement("div");
484
607
  this.el.className = "kzp-controls";
485
- this.build(opts.speeds);
608
+ this.build(opts.speeds, (_a = opts.captionLabels) != null ? _a : []);
609
+ document.addEventListener("click", this.onOutsideClick);
486
610
  }
487
- build(speeds) {
611
+ build(speeds, captionLabels) {
488
612
  if (this.config.playPause) {
489
613
  this.playBtn = this.button("kzp-play", "Play", ICONS.play);
490
614
  this.playBtn.addEventListener("click", () => this.cb.onToggle());
@@ -561,16 +685,72 @@ var Controls = class {
561
685
  }
562
686
  this.speedBtn.addEventListener("click", () => {
563
687
  this.speedMenu.hidden = !this.speedMenu.hidden;
688
+ if (this.ccMenu) this.ccMenu.hidden = true;
564
689
  });
565
690
  wrap.append(this.speedBtn, this.speedMenu);
566
691
  this.el.appendChild(wrap);
567
692
  }
693
+ if (this.config.captions && captionLabels.length) {
694
+ this.buildCaptions(captionLabels);
695
+ }
568
696
  if (this.config.fullscreen) {
569
697
  const btn = this.button("kzp-full", "Fullscreen", ICONS.full);
570
698
  btn.addEventListener("click", () => this.cb.onFullscreen());
571
699
  this.el.appendChild(btn);
572
700
  }
573
701
  }
702
+ /**
703
+ * One track: the CC button toggles it. Several: it opens a menu (Off + each
704
+ * track), styled like the speed menu. Sits left of the speed control.
705
+ */
706
+ buildCaptions(labels) {
707
+ const wrap = document.createElement("div");
708
+ wrap.className = "kzp-cc";
709
+ this.ccBtn = this.button("kzp-cc-btn", "Subtitles/closed captions", ICONS.captions);
710
+ this.ccBtn.setAttribute("aria-pressed", "false");
711
+ wrap.appendChild(this.ccBtn);
712
+ if (labels.length === 1) {
713
+ this.ccBtn.addEventListener("click", () => this.cb.onCaptions(this.ccActive === -1 ? 0 : -1));
714
+ } else {
715
+ this.ccMenu = document.createElement("div");
716
+ this.ccMenu.className = "kzp-speed-menu kzp-cc-menu";
717
+ this.ccMenu.hidden = true;
718
+ const items = [{ index: -1, label: "Off" }, ...labels.map((label, index) => ({ index, label }))];
719
+ for (const { index, label } of items) {
720
+ const item = document.createElement("button");
721
+ item.type = "button";
722
+ item.textContent = label;
723
+ item.dataset.track = String(index);
724
+ item.addEventListener("click", () => {
725
+ this.cb.onCaptions(index);
726
+ this.ccMenu.hidden = true;
727
+ });
728
+ this.ccMenu.appendChild(item);
729
+ }
730
+ this.ccBtn.addEventListener("click", () => {
731
+ this.ccMenu.hidden = !this.ccMenu.hidden;
732
+ if (this.speedMenu) this.speedMenu.hidden = true;
733
+ });
734
+ wrap.appendChild(this.ccMenu);
735
+ }
736
+ this.ccWrap = wrap;
737
+ const speed = this.el.querySelector(".kzp-speed");
738
+ this.el.insertBefore(wrap, speed);
739
+ }
740
+ /** Reflect the active caption track, and hide tracks (or the whole control) that failed to load. */
741
+ setCaptions(active, available) {
742
+ var _a;
743
+ this.ccActive = active;
744
+ if (!this.ccBtn || !this.ccWrap) return;
745
+ this.ccWrap.hidden = !available.some(Boolean);
746
+ this.ccBtn.classList.toggle("kzp-cc-btn--on", active !== -1);
747
+ this.ccBtn.setAttribute("aria-pressed", String(active !== -1));
748
+ (_a = this.ccMenu) == null ? void 0 : _a.querySelectorAll("button").forEach((item) => {
749
+ const index = Number(item.dataset.track);
750
+ item.hidden = index !== -1 && !available[index];
751
+ item.classList.toggle("kzp-menu-item--active", index === active);
752
+ });
753
+ }
574
754
  button(cls, label, svg) {
575
755
  const b = document.createElement("button");
576
756
  b.type = "button";
@@ -587,9 +767,14 @@ var Controls = class {
587
767
  }
588
768
  /** Push the latest state into the DOM. Called on every player timeupdate. */
589
769
  update(state, extras) {
770
+ var _a;
590
771
  if (this.playBtn) {
591
- this.playBtn.innerHTML = icon(state.playing ? ICONS.pause : ICONS.play);
592
- this.playBtn.setAttribute("aria-label", state.playing ? "Pause" : "Play");
772
+ const next = state.playing ? ICONS.pause : ICONS.play;
773
+ if (next !== this.playIcon) {
774
+ this.playIcon = next;
775
+ this.playBtn.innerHTML = icon(next);
776
+ this.playBtn.setAttribute("aria-label", state.playing ? "Pause" : "Play");
777
+ }
593
778
  }
594
779
  if (this.scrubber) {
595
780
  this.seekableMax = this.lockSeek ? state.furthest : Infinity;
@@ -600,14 +785,25 @@ var Controls = class {
600
785
  this.timeEl.textContent = `${fmt(state.current)} / ${fmt(state.duration)}`;
601
786
  }
602
787
  if (this.volBtn) {
603
- this.volBtn.innerHTML = icon(extras.muted || extras.volume === 0 ? ICONS.muted : ICONS.volume);
788
+ const next = extras.muted || extras.volume === 0 ? ICONS.muted : ICONS.volume;
789
+ if (next !== this.volIcon) {
790
+ this.volIcon = next;
791
+ this.volBtn.innerHTML = icon(next);
792
+ }
604
793
  }
605
794
  if (this.volRange && document.activeElement !== this.volRange) {
606
795
  this.volRange.value = String(extras.muted ? 0 : extras.volume);
607
796
  }
608
- if (this.speedBtn) this.speedBtn.textContent = `${extras.rate}x`;
797
+ if (this.speedBtn && extras.rate !== this.rate) {
798
+ this.rate = extras.rate;
799
+ this.speedBtn.textContent = `${extras.rate}x`;
800
+ (_a = this.speedMenu) == null ? void 0 : _a.querySelectorAll("button").forEach((item) => {
801
+ item.classList.toggle("kzp-menu-item--active", Number(item.dataset.rate) === extras.rate);
802
+ });
803
+ }
609
804
  }
610
805
  destroy() {
806
+ document.removeEventListener("click", this.onOutsideClick);
611
807
  this.el.remove();
612
808
  }
613
809
  };
@@ -620,13 +816,14 @@ var Player = class {
620
816
  this.container = container;
621
817
  this.options = options;
622
818
  this.emitter = new Emitter();
819
+ this.captions = null;
623
820
  this.furthest = 0;
624
821
  this.lastTick = 0;
625
822
  this.duration = 0;
626
823
  this.playing = false;
627
824
  this.ready = false;
628
825
  this.disposers = [];
629
- var _a, _b, _c, _d, _e, _f, _g;
826
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
630
827
  const speeds = (_a = options.speeds) != null ? _a : DEFAULT_SPEEDS;
631
828
  this.root = document.createElement("div");
632
829
  this.root.className = "kzp" + (options.className ? ` ${options.className}` : "");
@@ -641,6 +838,10 @@ var Player = class {
641
838
  this.stage.appendChild(hitLayer);
642
839
  this.provider = createProvider(options.source);
643
840
  const caps = this.provider.capabilities;
841
+ if ((_b = options.tracks) == null ? void 0 : _b.length) {
842
+ this.captions = new Captions(options.tracks, () => this.syncCaptions());
843
+ this.root.appendChild(this.captions.el);
844
+ }
644
845
  this.controls = new Controls(
645
846
  {
646
847
  onToggle: () => this.toggle(),
@@ -658,17 +859,22 @@ var Player = class {
658
859
  this.provider.setRate(r);
659
860
  this.render();
660
861
  },
862
+ onCaptions: (index) => {
863
+ var _a2;
864
+ return (_a2 = this.captions) == null ? void 0 : _a2.select(index);
865
+ },
661
866
  onFullscreen: () => this.toggleFullscreen()
662
867
  },
663
868
  {
664
869
  config: {
665
870
  ...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
871
+ seek: ((_d = (_c = options.controls) == null ? void 0 : _c.seek) != null ? _d : true) && caps.seekable,
872
+ speed: ((_f = (_e = options.controls) == null ? void 0 : _e.speed) != null ? _f : true) && caps.rate,
873
+ volume: ((_h = (_g = options.controls) == null ? void 0 : _g.volume) != null ? _h : true) && caps.volume
669
874
  },
670
875
  speeds,
671
- lockSeek: !!options.lockSeek
876
+ lockSeek: !!options.lockSeek,
877
+ captionLabels: (_i = this.captions) == null ? void 0 : _i.labels()
672
878
  }
673
879
  );
674
880
  this.root.appendChild(this.controls.el);
@@ -721,6 +927,7 @@ var Player = class {
721
927
  this.render();
722
928
  }
723
929
  onTimeUpdate(current) {
930
+ var _a;
724
931
  const advance = current - this.lastTick;
725
932
  if (advance > 0 && advance <= NATURAL_ADVANCE_MAX && current > this.furthest) {
726
933
  this.furthest = current;
@@ -728,9 +935,14 @@ var Player = class {
728
935
  this.provider.seek(this.furthest);
729
936
  }
730
937
  this.lastTick = current;
938
+ (_a = this.captions) == null ? void 0 : _a.update(current);
731
939
  this.emitter.emit("timeupdate", this.snapshot());
732
940
  this.render();
733
941
  }
942
+ syncCaptions() {
943
+ if (!this.captions) return;
944
+ this.controls.setCaptions(this.captions.getActive(), this.captions.available());
945
+ }
734
946
  snapshot() {
735
947
  const current = this.provider.getCurrentTime();
736
948
  const duration = this.duration || this.provider.getDuration();
@@ -753,21 +965,32 @@ var Player = class {
753
965
  }
754
966
  }
755
967
  // ---- Public API ----
968
+ // play()/pause() record the intent at once instead of waiting for the
969
+ // provider to confirm: YouTube only reports PLAYING after buffering, and
970
+ // until then a second click read "not playing" and called play() again
971
+ // (a no-op) instead of pausing. The provider's play/pause/ended events
972
+ // still correct it, e.g. when the browser blocks playback.
756
973
  play() {
974
+ this.playing = true;
757
975
  this.provider.play();
976
+ this.render();
758
977
  }
759
978
  pause() {
979
+ this.playing = false;
760
980
  this.provider.pause();
981
+ this.render();
761
982
  }
762
983
  toggle() {
763
- if (this.provider.isPlaying()) this.provider.pause();
764
- else this.provider.play();
984
+ if (this.playing) this.pause();
985
+ else this.play();
765
986
  }
766
987
  seek(seconds) {
988
+ var _a;
767
989
  if (!this.provider.capabilities.seekable) return;
768
990
  const target = this.options.lockSeek && seconds > this.furthest ? this.furthest : Math.max(0, seconds);
769
991
  this.provider.seek(target);
770
992
  this.lastTick = target;
993
+ (_a = this.captions) == null ? void 0 : _a.update(target);
771
994
  this.render();
772
995
  }
773
996
  setLockSeek(locked) {
@@ -786,9 +1009,11 @@ var Player = class {
786
1009
  this.emitter.off(event, cb);
787
1010
  }
788
1011
  destroy() {
1012
+ var _a;
789
1013
  for (const dispose of this.disposers) dispose();
790
1014
  this.disposers = [];
791
1015
  this.provider.destroy();
1016
+ (_a = this.captions) == null ? void 0 : _a.destroy();
792
1017
  this.controls.destroy();
793
1018
  this.emitter.clear();
794
1019
  this.root.remove();
@@ -800,6 +1025,6 @@ function createPlayer(container, options) {
800
1025
  return new Player(container, options);
801
1026
  }
802
1027
 
803
- export { Player, createPlayer };
1028
+ export { Player, createPlayer, parseVtt };
804
1029
  //# sourceMappingURL=index.js.map
805
1030
  //# sourceMappingURL=index.js.map