@arraypress/waveform-player 1.0.1 → 1.1.0

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.
@@ -5,6 +5,9 @@
5
5
  if (element.dataset.url) options.url = element.dataset.url;
6
6
  if (element.dataset.height) options.height = parseInt(element.dataset.height);
7
7
  if (element.dataset.samples) options.samples = parseInt(element.dataset.samples);
8
+ if (element.dataset.preload) {
9
+ options.preload = element.dataset.preload;
10
+ }
8
11
  if (element.dataset.waveformStyle) options.waveformStyle = element.dataset.waveformStyle;
9
12
  if (element.dataset.barWidth) options.barWidth = parseInt(element.dataset.barWidth);
10
13
  if (element.dataset.barSpacing) options.barSpacing = parseInt(element.dataset.barSpacing);
@@ -27,7 +30,32 @@
27
30
  if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === "true";
28
31
  if (element.dataset.title) options.title = element.dataset.title;
29
32
  if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
33
+ if (element.dataset.album) options.album = element.dataset.album;
34
+ if (element.dataset.artwork) options.artwork = element.dataset.artwork;
30
35
  if (element.dataset.waveform) options.waveform = element.dataset.waveform;
36
+ if (element.dataset.markers) {
37
+ try {
38
+ options.markers = JSON.parse(element.dataset.markers);
39
+ } catch (e) {
40
+ console.warn("Invalid markers JSON:", e);
41
+ }
42
+ }
43
+ if (element.dataset.playbackRate) {
44
+ options.playbackRate = parseFloat(element.dataset.playbackRate);
45
+ }
46
+ if (element.dataset.showPlaybackSpeed !== void 0) {
47
+ options.showPlaybackSpeed = element.dataset.showPlaybackSpeed === "true";
48
+ }
49
+ if (element.dataset.playbackRates) {
50
+ try {
51
+ options.playbackRates = JSON.parse(element.dataset.playbackRates);
52
+ } catch (e) {
53
+ console.warn("Invalid playbackRates JSON:", e);
54
+ }
55
+ }
56
+ if (element.dataset.enableMediaSession !== void 0) {
57
+ options.enableMediaSession = element.dataset.enableMediaSession === "true";
58
+ }
31
59
  return options;
32
60
  }
33
61
  function formatTime(seconds) {
@@ -439,24 +467,23 @@
439
467
  const maxPeak = Math.max(...peaks);
440
468
  return maxPeak > 0 ? peaks.map((peak) => peak / maxPeak) : peaks;
441
469
  }
442
- async function generateWaveform(url, samples = 200, includeBPM = false) {
443
- const response = await fetch(url);
444
- if (!response.ok) {
445
- throw new Error(`HTTP error! status: ${response.status}`);
446
- }
447
- const arrayBuffer = await response.arrayBuffer();
448
- const AudioContextClass = window.AudioContext || window.webkitAudioContext;
449
- const audioContext = new AudioContextClass();
470
+ async function generateWaveform(url, samples = 200, shouldDetectBPM = false) {
450
471
  try {
472
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
473
+ const response = await fetch(url);
474
+ const arrayBuffer = await response.arrayBuffer();
451
475
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
452
- const peaks = extractPeaks(audioBuffer, samples);
453
- const result = { peaks };
454
- if (includeBPM) {
455
- result.bpm = detectBPM(audioBuffer);
476
+ let peaks = extractPeaks(audioBuffer, samples);
477
+ peaks = normalizePeaks(peaks);
478
+ let bpm = null;
479
+ if (shouldDetectBPM) {
480
+ bpm = await detectBPM(audioBuffer);
456
481
  }
457
- return result;
458
- } finally {
459
- await audioContext.close();
482
+ audioContext.close();
483
+ return { peaks, bpm };
484
+ } catch (error) {
485
+ console.error("Failed to generate waveform:", error);
486
+ throw error;
460
487
  }
461
488
  }
462
489
  function generatePlaceholderWaveform(samples = 200) {
@@ -468,6 +495,12 @@
468
495
  }
469
496
  return data;
470
497
  }
498
+ function normalizePeaks(peaks, targetMax = 0.95) {
499
+ const maxPeak = Math.max(...peaks);
500
+ if (maxPeak === 0 || maxPeak > targetMax) return peaks;
501
+ const scaleFactor = targetMax / maxPeak;
502
+ return peaks.map((peak) => peak * scaleFactor);
503
+ }
471
504
 
472
505
  // src/js/themes.js
473
506
  var DEFAULT_OPTIONS = {
@@ -475,6 +508,12 @@
475
508
  url: "",
476
509
  height: 60,
477
510
  samples: 200,
511
+ preload: "metadata",
512
+ // Playback
513
+ playbackRate: 1,
514
+ showPlaybackSpeed: false,
515
+ playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
516
+ // Available speeds
478
517
  // Default waveform style
479
518
  waveformStyle: "mirror",
480
519
  barWidth: 2,
@@ -497,9 +536,15 @@
497
536
  showBPM: false,
498
537
  singlePlay: true,
499
538
  playOnSeek: true,
539
+ enableMediaSession: true,
540
+ // Markers
541
+ markers: [],
542
+ showMarkers: true,
500
543
  // Content
501
544
  title: null,
502
545
  subtitle: null,
546
+ artwork: null,
547
+ album: "",
503
548
  // Icons (SVG)
504
549
  playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
505
550
  pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
@@ -566,6 +611,9 @@
566
611
  _WaveformPlayer.instances.set(this.id, this);
567
612
  this.init();
568
613
  }
614
+ // ============================================
615
+ // Initialization
616
+ // ============================================
569
617
  /**
570
618
  * Initialize the player
571
619
  * @private
@@ -573,6 +621,8 @@
573
621
  init() {
574
622
  this.createDOM();
575
623
  this.createAudio();
624
+ this.initPlaybackSpeed();
625
+ this.initKeyboardControls();
576
626
  this.bindEvents();
577
627
  this.setupResizeObserver();
578
628
  requestAnimationFrame(() => {
@@ -596,58 +646,84 @@
596
646
  this.container.innerHTML = "";
597
647
  this.container.className = "waveform-player";
598
648
  this.container.innerHTML = `
599
- <div class="waveform-player-inner">
600
- <div class="waveform-body">
601
- <div class="waveform-track">
602
- <button class="waveform-btn" aria-label="Play/Pause" style="
603
- border-color: ${this.options.buttonColor};
604
- color: ${this.options.buttonColor};
605
- ">
606
- <span class="waveform-icon-play">${this.options.playIcon}</span>
607
- <span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
608
- </button>
609
-
610
- <div class="waveform-container">
611
- <canvas></canvas>
612
- <div class="waveform-loading" style="display:none;"></div>
613
- <div class="waveform-error" style="display:none;">
614
- <span class="waveform-error-text">Unable to load audio</span>
615
- </div>
616
- </div>
649
+ <div class="waveform-player-inner">
650
+ <div class="waveform-body">
651
+ <div class="waveform-track">
652
+ <button class="waveform-btn" aria-label="Play/Pause" style="
653
+ border-color: ${this.options.buttonColor};
654
+ color: ${this.options.buttonColor};
655
+ ">
656
+ <span class="waveform-icon-play">${this.options.playIcon}</span>
657
+ <span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
658
+ </button>
659
+
660
+ <div class="waveform-container">
661
+ <canvas></canvas>
662
+ <div class="waveform-markers"></div>
663
+ <div class="waveform-loading" style="display:none;"></div>
664
+ <div class="waveform-error" style="display:none;">
665
+ <span class="waveform-error-text">Unable to load audio</span>
617
666
  </div>
618
-
619
- <div class="waveform-info">
620
- <div class="waveform-text">
621
- <span class="waveform-title" style="color: ${this.options.textColor};"></span>
622
- ${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ""}
623
- </div>
624
- <div style="display: flex; align-items: center; gap: 1rem;">
625
- ${this.options.showBPM ? `
626
- <span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
627
- <span class="bpm-value">--</span> BPM
628
- </span>
629
- ` : ""}
630
- ${this.options.showTime ? `
631
- <span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
632
- <span class="time-current">0:00</span> / <span class="time-total">0:00</span>
633
- </span>
634
- ` : ""}
667
+ </div>
668
+ </div>
669
+
670
+ <div class="waveform-info">
671
+ ${this.options.artwork ? `
672
+ <img class="waveform-artwork" src="${this.options.artwork}" alt="Album artwork" style="
673
+ width: 40px;
674
+ height: 40px;
675
+ border-radius: 4px;
676
+ object-fit: cover;
677
+ flex-shrink: 0;
678
+ ">
679
+ ` : ""}
680
+ <div class="waveform-text">
681
+ <span class="waveform-title" style="color: ${this.options.textColor};"></span>
682
+ ${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ""}
683
+ </div>
684
+ <div style="display: flex; align-items: center; gap: 1rem;">
685
+ ${this.options.showBPM ? `
686
+ <span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
687
+ <span class="bpm-value">--</span> BPM
688
+ </span>
689
+ ` : ""}
690
+ ${this.options.showPlaybackSpeed ? `
691
+ <div class="waveform-speed">
692
+ <button class="speed-btn" aria-label="Playback speed">
693
+ <span class="speed-value">1x</span>
694
+ </button>
695
+ <div class="speed-menu" style="display: none;">
696
+ ${this.options.playbackRates.map(
697
+ (rate) => `<button class="speed-option" data-rate="${rate}">${rate}x</button>`
698
+ ).join("")}
699
+ </div>
635
700
  </div>
636
- </div>
701
+ ` : ""}
702
+ ${this.options.showTime ? `
703
+ <span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
704
+ <span class="time-current">0:00</span> / <span class="time-total">0:00</span>
705
+ </span>
706
+ ` : ""}
637
707
  </div>
638
708
  </div>
639
- `;
709
+ </div>
710
+ </div>
711
+ `;
640
712
  this.playBtn = this.container.querySelector(".waveform-btn");
641
713
  this.canvas = this.container.querySelector("canvas");
642
714
  this.ctx = this.canvas.getContext("2d");
643
715
  this.titleEl = this.container.querySelector(".waveform-title");
644
716
  this.subtitleEl = this.container.querySelector(".waveform-subtitle");
717
+ this.artworkEl = this.container.querySelector(".waveform-artwork");
645
718
  this.currentTimeEl = this.container.querySelector(".time-current");
646
719
  this.totalTimeEl = this.container.querySelector(".time-total");
647
720
  this.bpmEl = this.container.querySelector(".waveform-bpm");
648
721
  this.bpmValueEl = this.container.querySelector(".bpm-value");
649
722
  this.loadingEl = this.container.querySelector(".waveform-loading");
650
723
  this.errorEl = this.container.querySelector(".waveform-error");
724
+ this.markersContainer = this.container.querySelector(".waveform-markers");
725
+ this.speedBtn = this.container.querySelector(".speed-btn");
726
+ this.speedMenu = this.container.querySelector(".speed-menu");
651
727
  this.resizeCanvas();
652
728
  }
653
729
  /**
@@ -656,9 +732,119 @@
656
732
  */
657
733
  createAudio() {
658
734
  this.audio = new Audio();
659
- this.audio.preload = "metadata";
735
+ this.audio.preload = this.options.preload || "metadata";
660
736
  this.audio.crossOrigin = "anonymous";
661
737
  }
738
+ // ============================================
739
+ // Feature Initialization
740
+ // ============================================
741
+ /**
742
+ * Initialize playback speed controls
743
+ * @private
744
+ */
745
+ initPlaybackSpeed() {
746
+ if (this.options.playbackRate && this.options.playbackRate !== 1) {
747
+ this.audio.playbackRate = this.options.playbackRate;
748
+ }
749
+ if (this.options.showPlaybackSpeed) {
750
+ this.initSpeedControls();
751
+ }
752
+ }
753
+ /**
754
+ * Initialize speed control UI
755
+ * @private
756
+ */
757
+ initSpeedControls() {
758
+ const speedBtn = this.container.querySelector(".speed-btn");
759
+ const speedMenu = this.container.querySelector(".speed-menu");
760
+ if (!speedBtn || !speedMenu) return;
761
+ speedBtn.addEventListener("click", (e) => {
762
+ e.stopPropagation();
763
+ speedMenu.style.display = speedMenu.style.display === "none" ? "block" : "none";
764
+ });
765
+ document.addEventListener("click", () => {
766
+ speedMenu.style.display = "none";
767
+ });
768
+ speedMenu.addEventListener("click", (e) => {
769
+ e.stopPropagation();
770
+ if (e.target.classList.contains("speed-option")) {
771
+ const rate = parseFloat(e.target.dataset.rate);
772
+ this.setPlaybackRate(rate);
773
+ speedMenu.style.display = "none";
774
+ }
775
+ });
776
+ this.updateSpeedUI();
777
+ }
778
+ /**
779
+ * Initialize keyboard controls
780
+ * @private
781
+ */
782
+ initKeyboardControls() {
783
+ this.container.setAttribute("tabindex", "-1");
784
+ this.container.addEventListener("click", () => {
785
+ _WaveformPlayer.getAllInstances().forEach((player) => {
786
+ if (player !== this) {
787
+ player.container.setAttribute("tabindex", "-1");
788
+ }
789
+ });
790
+ this.container.setAttribute("tabindex", "0");
791
+ this.container.focus();
792
+ });
793
+ this.container.addEventListener("keydown", (e) => {
794
+ if (document.activeElement !== this.container) return;
795
+ const key = e.key;
796
+ const currentTime = this.audio.currentTime;
797
+ if (key >= "0" && key <= "9") {
798
+ e.preventDefault();
799
+ this.seekToPercent(parseInt(key) / 10);
800
+ return;
801
+ }
802
+ const actions = {
803
+ " ": () => this.togglePlay(),
804
+ "ArrowLeft": () => this.seekTo(Math.max(0, currentTime - 5)),
805
+ "ArrowRight": () => this.seekTo(Math.min(this.audio.duration, currentTime + 5)),
806
+ "ArrowUp": () => this.setVolume(Math.min(1, this.audio.volume + 0.1)),
807
+ "ArrowDown": () => this.setVolume(Math.max(0, this.audio.volume - 0.1)),
808
+ "m": () => this.audio.muted = !this.audio.muted,
809
+ "M": () => this.audio.muted = !this.audio.muted
810
+ };
811
+ if (actions[key]) {
812
+ e.preventDefault();
813
+ actions[key]();
814
+ }
815
+ });
816
+ }
817
+ /**
818
+ * Initialize Media Session API for system media controls
819
+ * @private
820
+ */
821
+ initMediaSession() {
822
+ if (!("mediaSession" in navigator) || !this.options.enableMediaSession) return;
823
+ navigator.mediaSession.metadata = new MediaMetadata({
824
+ title: this.options.title || "Unknown Track",
825
+ artist: this.options.subtitle || "",
826
+ album: this.options.album || "",
827
+ artwork: this.options.artwork ? [
828
+ { src: this.options.artwork, sizes: "512x512", type: "image/jpeg" }
829
+ ] : []
830
+ });
831
+ navigator.mediaSession.setActionHandler("play", () => this.play());
832
+ navigator.mediaSession.setActionHandler("pause", () => this.pause());
833
+ navigator.mediaSession.setActionHandler("seekbackward", () => {
834
+ this.seekTo(Math.max(0, this.audio.currentTime - 10));
835
+ });
836
+ navigator.mediaSession.setActionHandler("seekforward", () => {
837
+ this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
838
+ });
839
+ navigator.mediaSession.setActionHandler("seekto", (details) => {
840
+ if (details.seekTime !== null) {
841
+ this.seekTo(details.seekTime);
842
+ }
843
+ });
844
+ }
845
+ // ============================================
846
+ // Event Binding
847
+ // ============================================
662
848
  /**
663
849
  * Bind event listeners
664
850
  * @private
@@ -689,6 +875,9 @@
689
875
  }
690
876
  }
691
877
  }
878
+ // ============================================
879
+ // Audio Loading
880
+ // ============================================
692
881
  /**
693
882
  * Load audio file
694
883
  * @param {string} url - Audio URL
@@ -734,6 +923,8 @@
734
923
  }
735
924
  }
736
925
  this.drawWaveform();
926
+ this.renderMarkers();
927
+ this.initMediaSession();
737
928
  if (this.options.onLoad) {
738
929
  this.options.onLoad(this);
739
930
  }
@@ -744,6 +935,61 @@
744
935
  this.setLoading(false);
745
936
  }
746
937
  }
938
+ /**
939
+ * Load a new track
940
+ * @param {string} url - Audio URL
941
+ * @param {string} [title] - Track title
942
+ * @param {string} [subtitle] - Track subtitle
943
+ * @param {Object} [options] - Additional options
944
+ * @returns {Promise<void>}
945
+ */
946
+ async loadTrack(url, title = null, subtitle = null, options = {}) {
947
+ if (this.isPlaying) {
948
+ this.pause();
949
+ }
950
+ this.audio.src = "";
951
+ this.audio.load();
952
+ this.hasError = false;
953
+ if (this.errorEl) {
954
+ this.errorEl.style.display = "none";
955
+ }
956
+ if (this.canvas) {
957
+ this.canvas.style.opacity = "1";
958
+ }
959
+ if (this.playBtn) {
960
+ this.playBtn.disabled = false;
961
+ }
962
+ this.progress = 0;
963
+ this.waveformData = [];
964
+ this.options = mergeOptions(this.options, {
965
+ url,
966
+ title: title || this.options.title,
967
+ subtitle: subtitle || this.options.subtitle,
968
+ ...options
969
+ });
970
+ if (options.preload) {
971
+ this.audio.preload = options.preload;
972
+ }
973
+ if (this.subtitleEl) {
974
+ if (subtitle) {
975
+ this.subtitleEl.textContent = subtitle;
976
+ this.subtitleEl.style.display = "";
977
+ } else if (subtitle === "") {
978
+ this.subtitleEl.style.display = "none";
979
+ }
980
+ }
981
+ if (options.artwork && this.artworkEl) {
982
+ this.artworkEl.src = options.artwork;
983
+ }
984
+ if (options.markers) {
985
+ this.options.markers = options.markers;
986
+ }
987
+ await this.load(url);
988
+ this.play();
989
+ }
990
+ // ============================================
991
+ // Visualization
992
+ // ============================================
747
993
  /**
748
994
  * Set waveform data
749
995
  * @private
@@ -787,6 +1033,41 @@
787
1033
  this.canvas.parentElement.style.height = this.options.height + "px";
788
1034
  this.drawWaveform();
789
1035
  }
1036
+ /**
1037
+ * Render markers on the waveform
1038
+ * @private
1039
+ */
1040
+ renderMarkers() {
1041
+ if (!this.options.showMarkers || !this.options.markers?.length || !this.markersContainer) return;
1042
+ this.markersContainer.innerHTML = "";
1043
+ if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
1044
+ return;
1045
+ }
1046
+ this.options.markers.forEach((marker, index) => {
1047
+ const position = marker.time / this.audio.duration * 100;
1048
+ const markerEl = document.createElement("button");
1049
+ markerEl.className = "waveform-marker";
1050
+ markerEl.style.left = `${position}%`;
1051
+ markerEl.style.backgroundColor = marker.color || "rgba(255, 255, 255, 0.5)";
1052
+ markerEl.setAttribute("aria-label", marker.label);
1053
+ markerEl.setAttribute("data-time", marker.time);
1054
+ const tooltip = document.createElement("span");
1055
+ tooltip.className = "waveform-marker-tooltip";
1056
+ tooltip.textContent = marker.label;
1057
+ markerEl.appendChild(tooltip);
1058
+ markerEl.addEventListener("click", (e) => {
1059
+ e.stopPropagation();
1060
+ this.seekTo(marker.time);
1061
+ if (this.options.playOnSeek && !this.isPlaying) {
1062
+ this.play();
1063
+ }
1064
+ });
1065
+ this.markersContainer.appendChild(markerEl);
1066
+ });
1067
+ }
1068
+ // ============================================
1069
+ // Event Handlers
1070
+ // ============================================
790
1071
  /**
791
1072
  * Handle canvas click
792
1073
  * @private
@@ -816,6 +1097,7 @@
816
1097
  if (this.totalTimeEl) {
817
1098
  this.totalTimeEl.textContent = formatTime(this.audio.duration);
818
1099
  }
1100
+ this.renderMarkers();
819
1101
  }
820
1102
  /**
821
1103
  * Handle play event
@@ -886,6 +1168,9 @@
886
1168
  this.options.onError(error, this);
887
1169
  }
888
1170
  }
1171
+ // ============================================
1172
+ // Progress Updates
1173
+ // ============================================
889
1174
  /**
890
1175
  * Start smooth update animation
891
1176
  * @private
@@ -928,6 +1213,9 @@
928
1213
  this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
929
1214
  }
930
1215
  }
1216
+ // ============================================
1217
+ // UI Updates
1218
+ // ============================================
931
1219
  /**
932
1220
  * Update BPM display
933
1221
  * @private
@@ -938,6 +1226,20 @@
938
1226
  this.bpmEl.style.display = "inline-flex";
939
1227
  }
940
1228
  }
1229
+ /**
1230
+ * Update speed UI to reflect current rate
1231
+ * @private
1232
+ */
1233
+ updateSpeedUI() {
1234
+ const speedValue = this.container.querySelector(".speed-value");
1235
+ if (speedValue) {
1236
+ const rate = this.audio.playbackRate;
1237
+ speedValue.textContent = rate === 1 ? "1x" : `${rate}x`;
1238
+ }
1239
+ this.container.querySelectorAll(".speed-option").forEach((btn) => {
1240
+ btn.classList.toggle("active", parseFloat(btn.dataset.rate) === this.audio.playbackRate);
1241
+ });
1242
+ }
941
1243
  // ============================================
942
1244
  // Public API
943
1245
  // ============================================
@@ -970,6 +1272,16 @@
970
1272
  this.play();
971
1273
  }
972
1274
  }
1275
+ /**
1276
+ * Seek to time in seconds
1277
+ * @param {number} seconds - Time in seconds
1278
+ */
1279
+ seekTo(seconds) {
1280
+ if (this.audio && this.audio.duration) {
1281
+ this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
1282
+ this.updateProgress();
1283
+ }
1284
+ }
973
1285
  /**
974
1286
  * Seek to percentage
975
1287
  * @param {number} percent - Percentage (0-1)
@@ -989,6 +1301,17 @@
989
1301
  this.audio.volume = Math.max(0, Math.min(1, volume));
990
1302
  }
991
1303
  }
1304
+ /**
1305
+ * Set playback rate
1306
+ * @param {number} rate - Playback rate (0.5 to 2)
1307
+ */
1308
+ setPlaybackRate(rate) {
1309
+ if (!this.audio) return;
1310
+ const clampedRate = Math.max(0.5, Math.min(2, rate));
1311
+ this.audio.playbackRate = clampedRate;
1312
+ this.options.playbackRate = clampedRate;
1313
+ this.updateSpeedUI();
1314
+ }
992
1315
  /**
993
1316
  * Destroy player instance
994
1317
  */
@@ -1005,7 +1328,7 @@
1005
1328
  this.container.innerHTML = "";
1006
1329
  }
1007
1330
  // ============================================
1008
- // Static methods
1331
+ // Static Methods
1009
1332
  // ============================================
1010
1333
  /**
1011
1334
  * Get player instance by ID, element, or element ID