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