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