@arraypress/waveform-player 1.0.1 → 1.1.1

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,9 +5,13 @@
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);
14
+ if (element.dataset.buttonAlign) options.buttonAlign = element.dataset.buttonAlign;
11
15
  if (element.dataset.colorPreset) options.colorPreset = element.dataset.colorPreset;
12
16
  if (element.dataset.waveformColor) options.waveformColor = element.dataset.waveformColor;
13
17
  if (element.dataset.progressColor) options.progressColor = element.dataset.progressColor;
@@ -27,7 +31,32 @@
27
31
  if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === "true";
28
32
  if (element.dataset.title) options.title = element.dataset.title;
29
33
  if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
34
+ if (element.dataset.album) options.album = element.dataset.album;
35
+ if (element.dataset.artwork) options.artwork = element.dataset.artwork;
30
36
  if (element.dataset.waveform) options.waveform = element.dataset.waveform;
37
+ if (element.dataset.markers) {
38
+ try {
39
+ options.markers = JSON.parse(element.dataset.markers);
40
+ } catch (e) {
41
+ console.warn("Invalid markers JSON:", e);
42
+ }
43
+ }
44
+ if (element.dataset.playbackRate) {
45
+ options.playbackRate = parseFloat(element.dataset.playbackRate);
46
+ }
47
+ if (element.dataset.showPlaybackSpeed !== void 0) {
48
+ options.showPlaybackSpeed = element.dataset.showPlaybackSpeed === "true";
49
+ }
50
+ if (element.dataset.playbackRates) {
51
+ try {
52
+ options.playbackRates = JSON.parse(element.dataset.playbackRates);
53
+ } catch (e) {
54
+ console.warn("Invalid playbackRates JSON:", e);
55
+ }
56
+ }
57
+ if (element.dataset.enableMediaSession !== void 0) {
58
+ options.enableMediaSession = element.dataset.enableMediaSession === "true";
59
+ }
31
60
  return options;
32
61
  }
33
62
  function formatTime(seconds) {
@@ -439,24 +468,23 @@
439
468
  const maxPeak = Math.max(...peaks);
440
469
  return maxPeak > 0 ? peaks.map((peak) => peak / maxPeak) : peaks;
441
470
  }
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();
471
+ async function generateWaveform(url, samples = 200, shouldDetectBPM = false) {
450
472
  try {
473
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
474
+ const response = await fetch(url);
475
+ const arrayBuffer = await response.arrayBuffer();
451
476
  const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
452
- const peaks = extractPeaks(audioBuffer, samples);
453
- const result = { peaks };
454
- if (includeBPM) {
455
- result.bpm = detectBPM(audioBuffer);
477
+ let peaks = extractPeaks(audioBuffer, samples);
478
+ peaks = normalizePeaks(peaks);
479
+ let bpm = null;
480
+ if (shouldDetectBPM) {
481
+ bpm = await detectBPM(audioBuffer);
456
482
  }
457
- return result;
458
- } finally {
459
- await audioContext.close();
483
+ audioContext.close();
484
+ return { peaks, bpm };
485
+ } catch (error) {
486
+ console.error("Failed to generate waveform:", error);
487
+ throw error;
460
488
  }
461
489
  }
462
490
  function generatePlaceholderWaveform(samples = 200) {
@@ -468,6 +496,12 @@
468
496
  }
469
497
  return data;
470
498
  }
499
+ function normalizePeaks(peaks, targetMax = 0.95) {
500
+ const maxPeak = Math.max(...peaks);
501
+ if (maxPeak === 0 || maxPeak > targetMax) return peaks;
502
+ const scaleFactor = targetMax / maxPeak;
503
+ return peaks.map((peak) => peak * scaleFactor);
504
+ }
471
505
 
472
506
  // src/js/themes.js
473
507
  var DEFAULT_OPTIONS = {
@@ -475,6 +509,15 @@
475
509
  url: "",
476
510
  height: 60,
477
511
  samples: 200,
512
+ preload: "metadata",
513
+ // Playback
514
+ playbackRate: 1,
515
+ showPlaybackSpeed: false,
516
+ playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
517
+ // Available speeds
518
+ // Layout Options
519
+ buttonAlign: "auto",
520
+ // 'auto', 'top', 'center', 'bottom'
478
521
  // Default waveform style
479
522
  waveformStyle: "mirror",
480
523
  barWidth: 2,
@@ -497,9 +540,15 @@
497
540
  showBPM: false,
498
541
  singlePlay: true,
499
542
  playOnSeek: true,
543
+ enableMediaSession: true,
544
+ // Markers
545
+ markers: [],
546
+ showMarkers: true,
500
547
  // Content
501
548
  title: null,
502
549
  subtitle: null,
550
+ artwork: null,
551
+ album: "",
503
552
  // Icons (SVG)
504
553
  playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
505
554
  pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
@@ -566,6 +615,9 @@
566
615
  _WaveformPlayer.instances.set(this.id, this);
567
616
  this.init();
568
617
  }
618
+ // ============================================
619
+ // Initialization
620
+ // ============================================
569
621
  /**
570
622
  * Initialize the player
571
623
  * @private
@@ -573,6 +625,8 @@
573
625
  init() {
574
626
  this.createDOM();
575
627
  this.createAudio();
628
+ this.initPlaybackSpeed();
629
+ this.initKeyboardControls();
576
630
  this.bindEvents();
577
631
  this.setupResizeObserver();
578
632
  requestAnimationFrame(() => {
@@ -595,59 +649,94 @@
595
649
  createDOM() {
596
650
  this.container.innerHTML = "";
597
651
  this.container.className = "waveform-player";
652
+ let buttonAlign = this.options.buttonAlign;
653
+ if (buttonAlign === "auto") {
654
+ const style = this.options.waveformStyle;
655
+ if (style === "bars") {
656
+ buttonAlign = "bottom";
657
+ } else {
658
+ buttonAlign = "center";
659
+ }
660
+ }
598
661
  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>
662
+ <div class="waveform-player-inner">
663
+ <div class="waveform-body">
664
+ <div class="waveform-track waveform-align-${buttonAlign}">
665
+ <button class="waveform-btn" aria-label="Play/Pause" style="
666
+ border-color: ${this.options.buttonColor};
667
+ color: ${this.options.buttonColor};
668
+ ">
669
+ <span class="waveform-icon-play">${this.options.playIcon}</span>
670
+ <span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
671
+ </button>
672
+
673
+ <div class="waveform-container">
674
+ <canvas></canvas>
675
+ <div class="waveform-markers"></div>
676
+ <div class="waveform-loading" style="display:none;"></div>
677
+ <div class="waveform-error" style="display:none;">
678
+ <span class="waveform-error-text">Unable to load audio</span>
617
679
  </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
- ` : ""}
680
+ </div>
681
+ </div>
682
+
683
+ <div class="waveform-info">
684
+ ${this.options.artwork ? `
685
+ <img class="waveform-artwork" src="${this.options.artwork}" alt="Album artwork" style="
686
+ width: 40px;
687
+ height: 40px;
688
+ border-radius: 4px;
689
+ object-fit: cover;
690
+ flex-shrink: 0;
691
+ ">
692
+ ` : ""}
693
+ <div class="waveform-text">
694
+ <span class="waveform-title" style="color: ${this.options.textColor};"></span>
695
+ ${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ""}
696
+ </div>
697
+ <div style="display: flex; align-items: center; gap: 1rem;">
698
+ ${this.options.showBPM ? `
699
+ <span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
700
+ <span class="bpm-value">--</span> BPM
701
+ </span>
702
+ ` : ""}
703
+ ${this.options.showPlaybackSpeed ? `
704
+ <div class="waveform-speed">
705
+ <button class="speed-btn" aria-label="Playback speed">
706
+ <span class="speed-value">1x</span>
707
+ </button>
708
+ <div class="speed-menu" style="display: none;">
709
+ ${this.options.playbackRates.map(
710
+ (rate) => `<button class="speed-option" data-rate="${rate}">${rate}x</button>`
711
+ ).join("")}
712
+ </div>
635
713
  </div>
636
- </div>
714
+ ` : ""}
715
+ ${this.options.showTime ? `
716
+ <span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
717
+ <span class="time-current">0:00</span> / <span class="time-total">0:00</span>
718
+ </span>
719
+ ` : ""}
637
720
  </div>
638
721
  </div>
639
- `;
722
+ </div>
723
+ </div>
724
+ `;
640
725
  this.playBtn = this.container.querySelector(".waveform-btn");
641
726
  this.canvas = this.container.querySelector("canvas");
642
727
  this.ctx = this.canvas.getContext("2d");
643
728
  this.titleEl = this.container.querySelector(".waveform-title");
644
729
  this.subtitleEl = this.container.querySelector(".waveform-subtitle");
730
+ this.artworkEl = this.container.querySelector(".waveform-artwork");
645
731
  this.currentTimeEl = this.container.querySelector(".time-current");
646
732
  this.totalTimeEl = this.container.querySelector(".time-total");
647
733
  this.bpmEl = this.container.querySelector(".waveform-bpm");
648
734
  this.bpmValueEl = this.container.querySelector(".bpm-value");
649
735
  this.loadingEl = this.container.querySelector(".waveform-loading");
650
736
  this.errorEl = this.container.querySelector(".waveform-error");
737
+ this.markersContainer = this.container.querySelector(".waveform-markers");
738
+ this.speedBtn = this.container.querySelector(".speed-btn");
739
+ this.speedMenu = this.container.querySelector(".speed-menu");
651
740
  this.resizeCanvas();
652
741
  }
653
742
  /**
@@ -656,9 +745,119 @@
656
745
  */
657
746
  createAudio() {
658
747
  this.audio = new Audio();
659
- this.audio.preload = "metadata";
748
+ this.audio.preload = this.options.preload || "metadata";
660
749
  this.audio.crossOrigin = "anonymous";
661
750
  }
751
+ // ============================================
752
+ // Feature Initialization
753
+ // ============================================
754
+ /**
755
+ * Initialize playback speed controls
756
+ * @private
757
+ */
758
+ initPlaybackSpeed() {
759
+ if (this.options.playbackRate && this.options.playbackRate !== 1) {
760
+ this.audio.playbackRate = this.options.playbackRate;
761
+ }
762
+ if (this.options.showPlaybackSpeed) {
763
+ this.initSpeedControls();
764
+ }
765
+ }
766
+ /**
767
+ * Initialize speed control UI
768
+ * @private
769
+ */
770
+ initSpeedControls() {
771
+ const speedBtn = this.container.querySelector(".speed-btn");
772
+ const speedMenu = this.container.querySelector(".speed-menu");
773
+ if (!speedBtn || !speedMenu) return;
774
+ speedBtn.addEventListener("click", (e) => {
775
+ e.stopPropagation();
776
+ speedMenu.style.display = speedMenu.style.display === "none" ? "block" : "none";
777
+ });
778
+ document.addEventListener("click", () => {
779
+ speedMenu.style.display = "none";
780
+ });
781
+ speedMenu.addEventListener("click", (e) => {
782
+ e.stopPropagation();
783
+ if (e.target.classList.contains("speed-option")) {
784
+ const rate = parseFloat(e.target.dataset.rate);
785
+ this.setPlaybackRate(rate);
786
+ speedMenu.style.display = "none";
787
+ }
788
+ });
789
+ this.updateSpeedUI();
790
+ }
791
+ /**
792
+ * Initialize keyboard controls
793
+ * @private
794
+ */
795
+ initKeyboardControls() {
796
+ this.container.setAttribute("tabindex", "-1");
797
+ this.container.addEventListener("click", () => {
798
+ _WaveformPlayer.getAllInstances().forEach((player) => {
799
+ if (player !== this) {
800
+ player.container.setAttribute("tabindex", "-1");
801
+ }
802
+ });
803
+ this.container.setAttribute("tabindex", "0");
804
+ this.container.focus();
805
+ });
806
+ this.container.addEventListener("keydown", (e) => {
807
+ if (document.activeElement !== this.container) return;
808
+ const key = e.key;
809
+ const currentTime = this.audio.currentTime;
810
+ if (key >= "0" && key <= "9") {
811
+ e.preventDefault();
812
+ this.seekToPercent(parseInt(key) / 10);
813
+ return;
814
+ }
815
+ const actions = {
816
+ " ": () => this.togglePlay(),
817
+ "ArrowLeft": () => this.seekTo(Math.max(0, currentTime - 5)),
818
+ "ArrowRight": () => this.seekTo(Math.min(this.audio.duration, currentTime + 5)),
819
+ "ArrowUp": () => this.setVolume(Math.min(1, this.audio.volume + 0.1)),
820
+ "ArrowDown": () => this.setVolume(Math.max(0, this.audio.volume - 0.1)),
821
+ "m": () => this.audio.muted = !this.audio.muted,
822
+ "M": () => this.audio.muted = !this.audio.muted
823
+ };
824
+ if (actions[key]) {
825
+ e.preventDefault();
826
+ actions[key]();
827
+ }
828
+ });
829
+ }
830
+ /**
831
+ * Initialize Media Session API for system media controls
832
+ * @private
833
+ */
834
+ initMediaSession() {
835
+ if (!("mediaSession" in navigator) || !this.options.enableMediaSession) return;
836
+ navigator.mediaSession.metadata = new MediaMetadata({
837
+ title: this.options.title || "Unknown Track",
838
+ artist: this.options.subtitle || "",
839
+ album: this.options.album || "",
840
+ artwork: this.options.artwork ? [
841
+ { src: this.options.artwork, sizes: "512x512", type: "image/jpeg" }
842
+ ] : []
843
+ });
844
+ navigator.mediaSession.setActionHandler("play", () => this.play());
845
+ navigator.mediaSession.setActionHandler("pause", () => this.pause());
846
+ navigator.mediaSession.setActionHandler("seekbackward", () => {
847
+ this.seekTo(Math.max(0, this.audio.currentTime - 10));
848
+ });
849
+ navigator.mediaSession.setActionHandler("seekforward", () => {
850
+ this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
851
+ });
852
+ navigator.mediaSession.setActionHandler("seekto", (details) => {
853
+ if (details.seekTime !== null) {
854
+ this.seekTo(details.seekTime);
855
+ }
856
+ });
857
+ }
858
+ // ============================================
859
+ // Event Binding
860
+ // ============================================
662
861
  /**
663
862
  * Bind event listeners
664
863
  * @private
@@ -689,6 +888,9 @@
689
888
  }
690
889
  }
691
890
  }
891
+ // ============================================
892
+ // Audio Loading
893
+ // ============================================
692
894
  /**
693
895
  * Load audio file
694
896
  * @param {string} url - Audio URL
@@ -734,6 +936,8 @@
734
936
  }
735
937
  }
736
938
  this.drawWaveform();
939
+ this.renderMarkers();
940
+ this.initMediaSession();
737
941
  if (this.options.onLoad) {
738
942
  this.options.onLoad(this);
739
943
  }
@@ -744,6 +948,61 @@
744
948
  this.setLoading(false);
745
949
  }
746
950
  }
951
+ /**
952
+ * Load a new track
953
+ * @param {string} url - Audio URL
954
+ * @param {string} [title] - Track title
955
+ * @param {string} [subtitle] - Track subtitle
956
+ * @param {Object} [options] - Additional options
957
+ * @returns {Promise<void>}
958
+ */
959
+ async loadTrack(url, title = null, subtitle = null, options = {}) {
960
+ if (this.isPlaying) {
961
+ this.pause();
962
+ }
963
+ this.audio.src = "";
964
+ this.audio.load();
965
+ this.hasError = false;
966
+ if (this.errorEl) {
967
+ this.errorEl.style.display = "none";
968
+ }
969
+ if (this.canvas) {
970
+ this.canvas.style.opacity = "1";
971
+ }
972
+ if (this.playBtn) {
973
+ this.playBtn.disabled = false;
974
+ }
975
+ this.progress = 0;
976
+ this.waveformData = [];
977
+ this.options = mergeOptions(this.options, {
978
+ url,
979
+ title: title || this.options.title,
980
+ subtitle: subtitle || this.options.subtitle,
981
+ ...options
982
+ });
983
+ if (options.preload) {
984
+ this.audio.preload = options.preload;
985
+ }
986
+ if (this.subtitleEl) {
987
+ if (subtitle) {
988
+ this.subtitleEl.textContent = subtitle;
989
+ this.subtitleEl.style.display = "";
990
+ } else if (subtitle === "") {
991
+ this.subtitleEl.style.display = "none";
992
+ }
993
+ }
994
+ if (options.artwork && this.artworkEl) {
995
+ this.artworkEl.src = options.artwork;
996
+ }
997
+ if (options.markers) {
998
+ this.options.markers = options.markers;
999
+ }
1000
+ await this.load(url);
1001
+ this.play();
1002
+ }
1003
+ // ============================================
1004
+ // Visualization
1005
+ // ============================================
747
1006
  /**
748
1007
  * Set waveform data
749
1008
  * @private
@@ -787,6 +1046,41 @@
787
1046
  this.canvas.parentElement.style.height = this.options.height + "px";
788
1047
  this.drawWaveform();
789
1048
  }
1049
+ /**
1050
+ * Render markers on the waveform
1051
+ * @private
1052
+ */
1053
+ renderMarkers() {
1054
+ if (!this.options.showMarkers || !this.options.markers?.length || !this.markersContainer) return;
1055
+ this.markersContainer.innerHTML = "";
1056
+ if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
1057
+ return;
1058
+ }
1059
+ this.options.markers.forEach((marker, index) => {
1060
+ const position = marker.time / this.audio.duration * 100;
1061
+ const markerEl = document.createElement("button");
1062
+ markerEl.className = "waveform-marker";
1063
+ markerEl.style.left = `${position}%`;
1064
+ markerEl.style.backgroundColor = marker.color || "rgba(255, 255, 255, 0.5)";
1065
+ markerEl.setAttribute("aria-label", marker.label);
1066
+ markerEl.setAttribute("data-time", marker.time);
1067
+ const tooltip = document.createElement("span");
1068
+ tooltip.className = "waveform-marker-tooltip";
1069
+ tooltip.textContent = marker.label;
1070
+ markerEl.appendChild(tooltip);
1071
+ markerEl.addEventListener("click", (e) => {
1072
+ e.stopPropagation();
1073
+ this.seekTo(marker.time);
1074
+ if (this.options.playOnSeek && !this.isPlaying) {
1075
+ this.play();
1076
+ }
1077
+ });
1078
+ this.markersContainer.appendChild(markerEl);
1079
+ });
1080
+ }
1081
+ // ============================================
1082
+ // Event Handlers
1083
+ // ============================================
790
1084
  /**
791
1085
  * Handle canvas click
792
1086
  * @private
@@ -816,6 +1110,7 @@
816
1110
  if (this.totalTimeEl) {
817
1111
  this.totalTimeEl.textContent = formatTime(this.audio.duration);
818
1112
  }
1113
+ this.renderMarkers();
819
1114
  }
820
1115
  /**
821
1116
  * Handle play event
@@ -886,6 +1181,9 @@
886
1181
  this.options.onError(error, this);
887
1182
  }
888
1183
  }
1184
+ // ============================================
1185
+ // Progress Updates
1186
+ // ============================================
889
1187
  /**
890
1188
  * Start smooth update animation
891
1189
  * @private
@@ -928,6 +1226,9 @@
928
1226
  this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
929
1227
  }
930
1228
  }
1229
+ // ============================================
1230
+ // UI Updates
1231
+ // ============================================
931
1232
  /**
932
1233
  * Update BPM display
933
1234
  * @private
@@ -938,6 +1239,20 @@
938
1239
  this.bpmEl.style.display = "inline-flex";
939
1240
  }
940
1241
  }
1242
+ /**
1243
+ * Update speed UI to reflect current rate
1244
+ * @private
1245
+ */
1246
+ updateSpeedUI() {
1247
+ const speedValue = this.container.querySelector(".speed-value");
1248
+ if (speedValue) {
1249
+ const rate = this.audio.playbackRate;
1250
+ speedValue.textContent = rate === 1 ? "1x" : `${rate}x`;
1251
+ }
1252
+ this.container.querySelectorAll(".speed-option").forEach((btn) => {
1253
+ btn.classList.toggle("active", parseFloat(btn.dataset.rate) === this.audio.playbackRate);
1254
+ });
1255
+ }
941
1256
  // ============================================
942
1257
  // Public API
943
1258
  // ============================================
@@ -970,6 +1285,16 @@
970
1285
  this.play();
971
1286
  }
972
1287
  }
1288
+ /**
1289
+ * Seek to time in seconds
1290
+ * @param {number} seconds - Time in seconds
1291
+ */
1292
+ seekTo(seconds) {
1293
+ if (this.audio && this.audio.duration) {
1294
+ this.audio.currentTime = Math.max(0, Math.min(seconds, this.audio.duration));
1295
+ this.updateProgress();
1296
+ }
1297
+ }
973
1298
  /**
974
1299
  * Seek to percentage
975
1300
  * @param {number} percent - Percentage (0-1)
@@ -989,6 +1314,17 @@
989
1314
  this.audio.volume = Math.max(0, Math.min(1, volume));
990
1315
  }
991
1316
  }
1317
+ /**
1318
+ * Set playback rate
1319
+ * @param {number} rate - Playback rate (0.5 to 2)
1320
+ */
1321
+ setPlaybackRate(rate) {
1322
+ if (!this.audio) return;
1323
+ const clampedRate = Math.max(0.5, Math.min(2, rate));
1324
+ this.audio.playbackRate = clampedRate;
1325
+ this.options.playbackRate = clampedRate;
1326
+ this.updateSpeedUI();
1327
+ }
992
1328
  /**
993
1329
  * Destroy player instance
994
1330
  */
@@ -1005,7 +1341,7 @@
1005
1341
  this.container.innerHTML = "";
1006
1342
  }
1007
1343
  // ============================================
1008
- // Static methods
1344
+ // Static Methods
1009
1345
  // ============================================
1010
1346
  /**
1011
1347
  * Get player instance by ID, element, or element ID