@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.
- package/dist/waveform-player.css +1 -1
- package/dist/waveform-player.esm.js +58 -38
- package/dist/waveform-player.js +377 -54
- package/dist/waveform-player.min.js +58 -38
- package/package.json +2 -2
- package/src/css/waveform-player.css +176 -10
- package/src/js/audio.js +37 -19
- package/src/js/core.js +401 -40
- package/src/js/themes.js +15 -0
- package/src/js/utils.js +35 -1
package/dist/waveform-player.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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
|
|
1331
|
+
// Static Methods
|
|
1009
1332
|
// ============================================
|
|
1010
1333
|
/**
|
|
1011
1334
|
* Get player instance by ID, element, or element ID
|