@arraypress/waveform-player
Version:
Lightweight, customizable audio player with waveform visualization
1,381 lines (1,374 loc) • 54.3 kB
JavaScript
(() => {
// src/js/utils.js
function parseDataAttributes(element) {
const options = {};
if (element.dataset.url) options.url = element.dataset.url;
if (element.dataset.height) options.height = parseInt(element.dataset.height);
if (element.dataset.samples) options.samples = parseInt(element.dataset.samples);
if (element.dataset.preload) {
options.preload = element.dataset.preload;
}
if (element.dataset.waveformStyle) options.waveformStyle = element.dataset.waveformStyle;
if (element.dataset.barWidth) options.barWidth = parseInt(element.dataset.barWidth);
if (element.dataset.barSpacing) options.barSpacing = parseInt(element.dataset.barSpacing);
if (element.dataset.buttonAlign) options.buttonAlign = element.dataset.buttonAlign;
if (element.dataset.colorPreset) options.colorPreset = element.dataset.colorPreset;
if (element.dataset.waveformColor) options.waveformColor = element.dataset.waveformColor;
if (element.dataset.progressColor) options.progressColor = element.dataset.progressColor;
if (element.dataset.buttonColor) options.buttonColor = element.dataset.buttonColor;
if (element.dataset.buttonHoverColor) options.buttonHoverColor = element.dataset.buttonHoverColor;
if (element.dataset.textColor) options.textColor = element.dataset.textColor;
if (element.dataset.textSecondaryColor) options.textSecondaryColor = element.dataset.textSecondaryColor;
if (element.dataset.backgroundColor) options.backgroundColor = element.dataset.backgroundColor;
if (element.dataset.borderColor) options.borderColor = element.dataset.borderColor;
if (element.dataset.color) options.waveformColor = element.dataset.color;
if (element.dataset.theme) options.colorPreset = element.dataset.theme;
if (element.dataset.autoplay) options.autoplay = element.dataset.autoplay === "true";
if (element.dataset.showTime) options.showTime = element.dataset.showTime === "true";
if (element.dataset.showHoverTime) options.showHoverTime = element.dataset.showHoverTime === "true";
if (element.dataset.showBpm) options.showBPM = element.dataset.showBpm === "true";
if (element.dataset.singlePlay) options.singlePlay = element.dataset.singlePlay === "true";
if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === "true";
if (element.dataset.title) options.title = element.dataset.title;
if (element.dataset.subtitle) options.subtitle = element.dataset.subtitle;
if (element.dataset.album) options.album = element.dataset.album;
if (element.dataset.artwork) options.artwork = element.dataset.artwork;
if (element.dataset.waveform) options.waveform = element.dataset.waveform;
if (element.dataset.markers) {
try {
options.markers = JSON.parse(element.dataset.markers);
} catch (e) {
console.warn("Invalid markers JSON:", e);
}
}
if (element.dataset.playbackRate) {
options.playbackRate = parseFloat(element.dataset.playbackRate);
}
if (element.dataset.showPlaybackSpeed !== void 0) {
options.showPlaybackSpeed = element.dataset.showPlaybackSpeed === "true";
}
if (element.dataset.playbackRates) {
try {
options.playbackRates = JSON.parse(element.dataset.playbackRates);
} catch (e) {
console.warn("Invalid playbackRates JSON:", e);
}
}
if (element.dataset.enableMediaSession !== void 0) {
options.enableMediaSession = element.dataset.enableMediaSession === "true";
}
return options;
}
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function generateId(url) {
const str = url || Math.random().toString();
return btoa(str.substring(0, 10)).replace(/[^a-zA-Z0-9]/g, "");
}
function extractTitleFromUrl(url) {
if (!url) return "Audio";
const parts = url.split("/");
const filename = parts[parts.length - 1];
const name = filename.split(".")[0];
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
}
function mergeOptions(...sources) {
const result = {};
for (const source of sources) {
for (const key in source) {
if (source[key] !== null && source[key] !== void 0) {
result[key] = source[key];
}
}
}
return result;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
function resampleData(data, targetLength) {
if (data.length === targetLength) return data;
if (data.length === 0 || targetLength === 0) return [];
const result = [];
if (targetLength > data.length) {
const ratio = (data.length - 1) / (targetLength - 1);
for (let i = 0; i < targetLength; i++) {
const index = i * ratio;
const lower = Math.floor(index);
const upper = Math.ceil(index);
const fraction = index - lower;
if (upper >= data.length) {
result.push(data[data.length - 1]);
} else if (lower === upper) {
result.push(data[lower]);
} else {
const value = data[lower] * (1 - fraction) + data[upper] * fraction;
result.push(value);
}
}
} else {
const bucketSize = data.length / targetLength;
for (let i = 0; i < targetLength; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.floor((i + 1) * bucketSize);
let max = 0;
let count = 0;
for (let j = start; j <= end && j < data.length; j++) {
if (data[j] > max) {
max = data[j];
}
count++;
}
if (count === 0) {
const nearestIndex = Math.min(Math.round(i * bucketSize), data.length - 1);
max = data[nearestIndex];
}
result.push(max);
}
}
return result;
}
// src/js/drawing.js
function drawBars(ctx, canvas, peaks, progress, options) {
const dpr = window.devicePixelRatio || 1;
const barWidth = options.barWidth * dpr;
const barSpacing = options.barSpacing * dpr;
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
const resampledPeaks = resampleData(peaks, barCount);
const height = canvas.height;
const progressWidth = progress * canvas.width;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < resampledPeaks.length; i++) {
const x = i * (barWidth + barSpacing);
if (x + barWidth > canvas.width) break;
const peakHeight = resampledPeaks[i] * height * 0.9;
const y = height - peakHeight;
ctx.fillStyle = options.color;
ctx.fillRect(x, y, barWidth, peakHeight);
}
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, progressWidth, height);
ctx.clip();
for (let i = 0; i < resampledPeaks.length; i++) {
const x = i * (barWidth + barSpacing);
if (x > progressWidth) break;
const peakHeight = resampledPeaks[i] * height * 0.9;
const y = height - peakHeight;
ctx.fillStyle = options.progressColor;
ctx.fillRect(x, y, barWidth, peakHeight);
}
ctx.restore();
}
function drawMirror(ctx, canvas, peaks, progress, options) {
const dpr = window.devicePixelRatio || 1;
const barWidth = options.barWidth * dpr;
const barSpacing = options.barSpacing * dpr;
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
const resampledPeaks = resampleData(peaks, barCount);
const height = canvas.height;
const centerY = height / 2;
const progressWidth = progress * canvas.width;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < resampledPeaks.length; i++) {
const x = i * (barWidth + barSpacing);
if (x + barWidth > canvas.width) break;
const peakHeight = resampledPeaks[i] * height * 0.45;
ctx.fillStyle = options.color;
ctx.fillRect(x, centerY - peakHeight, barWidth, peakHeight);
ctx.fillRect(x, centerY, barWidth, peakHeight);
}
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, progressWidth, height);
ctx.clip();
for (let i = 0; i < resampledPeaks.length; i++) {
const x = i * (barWidth + barSpacing);
if (x > progressWidth) break;
const peakHeight = resampledPeaks[i] * height * 0.45;
ctx.fillStyle = options.progressColor;
ctx.fillRect(x, centerY - peakHeight, barWidth, peakHeight);
ctx.fillRect(x, centerY, barWidth, peakHeight);
}
ctx.restore();
}
function drawLine(ctx, canvas, peaks, progress, options) {
const width = canvas.width;
const height = canvas.height;
const centerY = height / 2;
const amplitude = height * 0.35;
ctx.clearRect(0, 0, width, height);
const drawCurve = (color, lineWidth, endProgress = 1, addGlow = false) => {
if (addGlow) {
ctx.shadowBlur = 12;
ctx.shadowColor = color;
}
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(0, centerY);
const points = [];
const samples = Math.floor(peaks.length * endProgress);
for (let i = 0; i < samples; i++) {
const x = i / (peaks.length - 1) * width;
const peakValue = peaks[i];
const waveOffset = Math.sin(i * 0.1) * peakValue;
const y = centerY + waveOffset * amplitude;
points.push({ x, y });
}
for (let i = 0; i < points.length - 1; i++) {
const cp1x = points[i].x + (points[i + 1].x - points[i].x) * 0.5;
const cp1y = points[i].y;
const cp2x = points[i + 1].x - (points[i + 1].x - points[i].x) * 0.5;
const cp2y = points[i + 1].y;
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, points[i + 1].x, points[i + 1].y);
}
ctx.stroke();
if (addGlow) {
ctx.shadowBlur = 0;
}
};
ctx.strokeStyle = "rgba(255, 255, 255, 0.03)";
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, centerY);
ctx.lineTo(width, centerY);
ctx.stroke();
for (let i = 0; i <= 10; i++) {
const x = width / 10 * i;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
drawCurve(options.color, 2, 1, false);
if (progress > 0) {
drawCurve(options.progressColor, 3, progress, true);
}
}
function drawBlocks(ctx, canvas, peaks, progress, options) {
const dpr = window.devicePixelRatio || 1;
const barWidth = (options.barWidth || 3) * dpr;
const barSpacing = (options.barSpacing || 1) * dpr;
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
const resampledPeaks = resampleData(peaks, barCount);
const height = canvas.height;
const blockSize = 4 * dpr;
const blockGap = 2 * dpr;
const progressWidth = progress * canvas.width;
const centerY = height / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < resampledPeaks.length; i++) {
const x = i * (barWidth + barSpacing);
if (x + barWidth > canvas.width) break;
const peakHeight = resampledPeaks[i] * height * 0.9;
const blockCount = Math.floor(peakHeight / (blockSize + blockGap));
ctx.fillStyle = x < progressWidth ? options.progressColor : options.color;
for (let j = 0; j < blockCount; j++) {
const blockOffset = j * (blockSize + blockGap);
ctx.fillRect(x, centerY - blockOffset - blockSize, barWidth, blockSize);
if (j > 0) {
ctx.fillRect(x, centerY + blockOffset, barWidth, blockSize);
}
}
}
}
function drawDots(ctx, canvas, peaks, progress, options) {
const dpr = window.devicePixelRatio || 1;
const barWidth = (options.barWidth || 2) * dpr;
const barSpacing = (options.barSpacing || 3) * dpr;
const barCount = Math.floor(canvas.width / (barWidth + barSpacing));
const resampledPeaks = resampleData(peaks, barCount);
const height = canvas.height;
const dotRadius = Math.max(1.5 * dpr, barWidth / 2);
const progressWidth = progress * canvas.width;
const centerY = height / 2;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < resampledPeaks.length; i++) {
const x = i * (barWidth + barSpacing) + barWidth / 2;
if (x > canvas.width) break;
const peakHeight = resampledPeaks[i] * height * 0.9;
ctx.fillStyle = x < progressWidth ? options.progressColor : options.color;
ctx.beginPath();
ctx.arc(x, centerY - peakHeight / 2, dotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(x, centerY + peakHeight / 2, dotRadius, 0, Math.PI * 2);
ctx.fill();
}
}
function drawSeekbar(ctx, canvas, peaks, progress, options) {
const width = canvas.width;
const height = canvas.height;
const centerY = height / 2;
const barHeight = 4;
const borderRadius = barHeight / 2;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = options.color || "rgba(255, 255, 255, 0.2)";
ctx.beginPath();
ctx.moveTo(borderRadius, centerY - barHeight / 2);
ctx.lineTo(width - borderRadius, centerY - barHeight / 2);
ctx.arc(width - borderRadius, centerY, barHeight / 2, -Math.PI / 2, Math.PI / 2);
ctx.lineTo(borderRadius, centerY + barHeight / 2);
ctx.arc(borderRadius, centerY, barHeight / 2, Math.PI / 2, -Math.PI / 2);
ctx.closePath();
ctx.fill();
if (progress > 0) {
const progressWidth = Math.max(borderRadius * 2, progress * width);
ctx.shadowBlur = 8;
ctx.shadowColor = options.progressColor;
ctx.fillStyle = options.progressColor || "rgba(255, 255, 255, 0.9)";
ctx.beginPath();
ctx.moveTo(borderRadius, centerY - barHeight / 2);
ctx.lineTo(progressWidth - borderRadius, centerY - barHeight / 2);
ctx.arc(progressWidth - borderRadius, centerY, barHeight / 2, -Math.PI / 2, Math.PI / 2);
ctx.lineTo(borderRadius, centerY + barHeight / 2);
ctx.arc(borderRadius, centerY, barHeight / 2, Math.PI / 2, -Math.PI / 2);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
const handleRadius = 8;
const handleX = progressWidth;
ctx.shadowBlur = 4;
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
ctx.shadowOffsetY = 2;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(handleX, centerY, handleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
ctx.fillStyle = options.progressColor || "rgba(255, 255, 255, 0.9)";
ctx.beginPath();
ctx.arc(handleX, centerY, handleRadius * 0.4, 0, Math.PI * 2);
ctx.fill();
}
}
var DRAWING_STYLES = {
"bars": drawBars,
// Classic vertical bars
"mirror": drawMirror,
// SoundCloud-style symmetrical
"line": drawLine,
// Smooth oscilloscope wave
"blocks": drawBlocks,
// LED meter segmented
"dots": drawDots,
// Circular points
"seekbar": drawSeekbar
// Simple progress bar (no waveform)
};
function draw(ctx, canvas, peaks, progress, options) {
const drawFunc = DRAWING_STYLES[options.waveformStyle] || drawBars;
drawFunc(ctx, canvas, peaks, progress, options);
}
// src/js/bpm.js
function detectBPM(buffer) {
try {
const channelData = buffer.getChannelData(0);
const sampleRate = buffer.sampleRate;
const onsets = detectOnsets(channelData, sampleRate);
if (onsets.length < 2) return 120;
const intervals = [];
for (let i = 1; i < onsets.length; i++) {
intervals.push((onsets[i] - onsets[i - 1]) / sampleRate);
}
const tempoGroups = {};
intervals.forEach((interval) => {
const tempo = 60 / interval;
const bucket = Math.round(tempo / 3) * 3;
if (bucket > 60 && bucket < 200) {
tempoGroups[bucket] = (tempoGroups[bucket] || 0) + 1;
}
});
let maxCount = 0;
let detectedBPM = 120;
for (const [tempo, count] of Object.entries(tempoGroups)) {
if (count > maxCount) {
maxCount = count;
detectedBPM = parseInt(tempo);
}
}
if (detectedBPM < 70 && tempoGroups[detectedBPM * 2]) {
detectedBPM *= 2;
} else if (detectedBPM > 160 && tempoGroups[Math.round(detectedBPM / 2)]) {
detectedBPM = Math.round(detectedBPM / 2);
}
return detectedBPM - 1;
} catch (e) {
console.warn("BPM detection failed:", e);
return null;
}
}
function detectOnsets(channelData, sampleRate) {
const windowSize = 2048;
const hopSize = windowSize / 2;
const onsets = [];
let previousEnergy = 0;
for (let i = 0; i < channelData.length - windowSize; i += hopSize) {
let energy = 0;
for (let j = i; j < i + windowSize; j++) {
energy += channelData[j] * channelData[j];
}
energy = energy / windowSize;
const energyDiff = energy - previousEnergy;
const threshold = previousEnergy * 1.8 + 0.01;
if (energyDiff > threshold && energy > 0.01) {
const lastOnset = onsets[onsets.length - 1] || 0;
const minDistance = sampleRate * 0.15;
if (i - lastOnset > minDistance) {
onsets.push(i);
}
}
previousEnergy = energy * 0.8 + previousEnergy * 0.2;
}
return onsets;
}
// src/js/audio.js
function extractPeaks(buffer, samples = 200) {
const sampleSize = buffer.length / samples;
const sampleStep = ~~(sampleSize / 10) || 1;
const channels = buffer.numberOfChannels;
const peaks = [];
for (let c = 0; c < channels; c++) {
const chan = buffer.getChannelData(c);
for (let i = 0; i < samples; i++) {
const start = ~~(i * sampleSize);
const end = ~~(start + sampleSize);
let min = 0;
let max = 0;
for (let j = start; j < end; j += sampleStep) {
const value = chan[j];
if (value > max) max = value;
if (value < min) min = value;
}
const peak = Math.max(Math.abs(max), Math.abs(min));
if (c === 0 || peak > peaks[i]) {
peaks[i] = peak;
}
}
}
const maxPeak = Math.max(...peaks);
return maxPeak > 0 ? peaks.map((peak) => peak / maxPeak) : peaks;
}
async function generateWaveform(url, samples = 200, shouldDetectBPM = false) {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
let peaks = extractPeaks(audioBuffer, samples);
peaks = normalizePeaks(peaks);
let bpm = null;
if (shouldDetectBPM) {
bpm = await detectBPM(audioBuffer);
}
audioContext.close();
return { peaks, bpm };
} catch (error) {
console.error("Failed to generate waveform:", error);
throw error;
}
}
function generatePlaceholderWaveform(samples = 200) {
const data = [];
for (let i = 0; i < samples; i++) {
const base = Math.random() * 0.5 + 0.3;
const variation = Math.sin(i / samples * Math.PI * 4) * 0.2;
data.push(Math.max(0.1, Math.min(1, base + variation)));
}
return data;
}
function normalizePeaks(peaks, targetMax = 0.95) {
const maxPeak = Math.max(...peaks);
if (maxPeak === 0 || maxPeak > targetMax) return peaks;
const scaleFactor = targetMax / maxPeak;
return peaks.map((peak) => peak * scaleFactor);
}
// src/js/themes.js
function detectColorScheme() {
const root = document.documentElement;
const body = document.body;
if (root.classList.contains("dark") || root.classList.contains("dark-mode") || root.classList.contains("theme-dark") || root.getAttribute("data-theme") === "dark" || root.getAttribute("data-color-scheme") === "dark" || body.classList.contains("dark") || body.classList.contains("dark-mode") || body.getAttribute("data-theme") === "dark") {
return "dark";
}
if (root.classList.contains("light") || root.classList.contains("light-mode") || root.classList.contains("theme-light") || root.getAttribute("data-theme") === "light" || root.getAttribute("data-color-scheme") === "light" || body.classList.contains("light") || body.classList.contains("light-mode") || body.getAttribute("data-theme") === "light") {
return "light";
}
try {
const bodyBg = getComputedStyle(document.body).backgroundColor;
const rgb = bodyBg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const [r, g, b] = rgb.map(Number);
const brightness = (r * 299 + g * 587 + b * 114) / 1e3;
if (brightness > 128) {
return "light";
} else if (brightness < 128) {
return "dark";
}
}
} catch (e) {
}
if (window.matchMedia) {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
if (window.matchMedia("(prefers-color-scheme: light)").matches) {
return "light";
}
}
return "dark";
}
var COLOR_PRESETS = {
dark: {
waveformColor: "rgba(255, 255, 255, 0.3)",
progressColor: "rgba(255, 255, 255, 0.9)",
buttonColor: "rgba(255, 255, 255, 0.9)",
buttonHoverColor: "rgba(255, 255, 255, 1)",
textColor: "#ffffff",
textSecondaryColor: "rgba(255, 255, 255, 0.6)",
backgroundColor: "rgba(255, 255, 255, 0.03)",
borderColor: "rgba(255, 255, 255, 0.1)"
},
light: {
waveformColor: "rgba(0, 0, 0, 0.2)",
progressColor: "rgba(0, 0, 0, 0.8)",
buttonColor: "rgba(0, 0, 0, 0.8)",
buttonHoverColor: "rgba(0, 0, 0, 0.9)",
textColor: "#333333",
textSecondaryColor: "rgba(0, 0, 0, 0.6)",
backgroundColor: "rgba(0, 0, 0, 0.02)",
borderColor: "rgba(0, 0, 0, 0.1)"
}
};
function getColorPreset(presetName) {
if (presetName && COLOR_PRESETS[presetName]) {
return COLOR_PRESETS[presetName];
}
const detected = detectColorScheme();
return COLOR_PRESETS[detected];
}
var DEFAULT_OPTIONS = {
// Core settings
url: "",
height: 60,
samples: 200,
preload: "metadata",
// Playback
playbackRate: 1,
showPlaybackSpeed: false,
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
// Layout Options
buttonAlign: "auto",
// Default waveform style
waveformStyle: "mirror",
barWidth: 2,
barSpacing: 0,
// Color preset: null = auto-detect, 'dark' = force dark, 'light' = force light
colorPreset: null,
// Individual color overrides (null means use preset)
waveformColor: null,
progressColor: null,
buttonColor: null,
buttonHoverColor: null,
textColor: null,
textSecondaryColor: null,
backgroundColor: null,
borderColor: null,
// Features
autoplay: false,
showTime: true,
showHoverTime: false,
showBPM: false,
singlePlay: true,
playOnSeek: true,
enableMediaSession: true,
// Markers
markers: [],
showMarkers: true,
// Content
title: null,
subtitle: null,
artwork: null,
album: "",
// Icons (SVG)
playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
// Callbacks
onLoad: null,
onPlay: null,
onPause: null,
onEnd: null,
onError: null,
onTimeUpdate: null
};
var STYLE_DEFAULTS = {
bars: { barWidth: 3, barSpacing: 1 },
mirror: { barWidth: 2, barSpacing: 0 },
line: { barWidth: 2, barSpacing: 0 },
blocks: { barWidth: 4, barSpacing: 2 },
dots: { barWidth: 3, barSpacing: 3 },
seekbar: { barWidth: 1, barSpacing: 0 }
};
// src/js/core.js
var WaveformPlayer = class _WaveformPlayer {
/** @type {Map<string, WaveformPlayer>} */
static instances = /* @__PURE__ */ new Map();
/** @type {WaveformPlayer|null} */
static currentlyPlaying = null;
/**
* Create a new WaveformPlayer instance
* @param {string|HTMLElement} container - Container element or selector
* @param {Object} options - Player options
*/
constructor(container, options = {}) {
this.container = typeof container === "string" ? document.querySelector(container) : container;
if (!this.container) {
throw new Error("WaveformPlayer: Container element not found");
}
const dataOptions = parseDataAttributes(this.container);
this.options = mergeOptions(DEFAULT_OPTIONS, dataOptions, options);
const preset = getColorPreset(this.options.colorPreset);
for (const [key, value] of Object.entries(preset)) {
if (this.options[key] === null || this.options[key] === void 0) {
this.options[key] = value;
}
}
const styleDefaults = STYLE_DEFAULTS[this.options.waveformStyle];
if (styleDefaults) {
if (dataOptions.barWidth === void 0 && options.barWidth === void 0) {
this.options.barWidth = styleDefaults.barWidth;
}
if (dataOptions.barSpacing === void 0 && options.barSpacing === void 0) {
this.options.barSpacing = styleDefaults.barSpacing;
}
}
this.audio = null;
this.canvas = null;
this.ctx = null;
this.waveformData = [];
this.progress = 0;
this.isPlaying = false;
this.isLoading = false;
this.hasError = false;
this.updateTimer = null;
this.resizeObserver = null;
this.id = this.container.id || generateId(this.options.url);
_WaveformPlayer.instances.set(this.id, this);
this.init();
setTimeout(() => {
this.container.dispatchEvent(new CustomEvent("waveformplayer:ready", {
detail: { player: this, url: this.options.url }
}));
}, 100);
}
// ============================================
// Initialization
// ============================================
/**
* Initialize the player
* @private
*/
init() {
this.createDOM();
this.createAudio();
this.initPlaybackSpeed();
this.initKeyboardControls();
this.bindEvents();
this.setupResizeObserver();
requestAnimationFrame(() => {
this.resizeCanvas();
if (this.options.url) {
this.load(this.options.url).then(() => {
if (this.options.autoplay) {
this.play();
}
}).catch((error) => {
console.error("Failed to load audio:", error);
});
}
});
}
/**
* Create DOM elements
* @private
*/
createDOM() {
this.container.innerHTML = "";
this.container.className = "waveform-player";
let buttonAlign = this.options.buttonAlign;
if (buttonAlign === "auto") {
const style = this.options.waveformStyle;
if (style === "bars") {
buttonAlign = "bottom";
} else {
buttonAlign = "center";
}
}
this.container.innerHTML = `
<div class="waveform-player-inner">
<div class="waveform-body">
<div class="waveform-track waveform-align-${buttonAlign}">
<button class="waveform-btn" aria-label="Play/Pause" style="
border-color: ${this.options.buttonColor};
color: ${this.options.buttonColor};
">
<span class="waveform-icon-play">${this.options.playIcon}</span>
<span class="waveform-icon-pause" style="display:none;">${this.options.pauseIcon}</span>
</button>
<div class="waveform-container">
<canvas></canvas>
<div class="waveform-markers"></div>
<div class="waveform-loading" style="display:none;"></div>
<div class="waveform-error" style="display:none;">
<span class="waveform-error-text">Unable to load audio</span>
</div>
</div>
</div>
<div class="waveform-info">
${this.options.artwork ? `
<img class="waveform-artwork" src="${this.options.artwork}" alt="Album artwork" style="
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
">
` : ""}
<div class="waveform-text">
<span class="waveform-title" style="color: ${this.options.textColor};"></span>
${this.options.subtitle ? `<span class="waveform-subtitle" style="color: ${this.options.textSecondaryColor};">${this.options.subtitle}</span>` : ""}
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
${this.options.showBPM ? `
<span class="waveform-bpm" style="color: ${this.options.textSecondaryColor}; display: none;">
<span class="bpm-value">--</span> BPM
</span>
` : ""}
${this.options.showPlaybackSpeed ? `
<div class="waveform-speed">
<button class="speed-btn" aria-label="Playback speed">
<span class="speed-value">1x</span>
</button>
<div class="speed-menu" style="display: none;">
${this.options.playbackRates.map(
(rate) => `<button class="speed-option" data-rate="${rate}">${rate}x</button>`
).join("")}
</div>
</div>
` : ""}
${this.options.showTime ? `
<span class="waveform-time" style="color: ${this.options.textSecondaryColor};">
<span class="time-current">0:00</span> / <span class="time-total">0:00</span>
</span>
` : ""}
</div>
</div>
</div>
</div>
`;
this.playBtn = this.container.querySelector(".waveform-btn");
this.canvas = this.container.querySelector("canvas");
this.ctx = this.canvas.getContext("2d");
this.titleEl = this.container.querySelector(".waveform-title");
this.subtitleEl = this.container.querySelector(".waveform-subtitle");
this.artworkEl = this.container.querySelector(".waveform-artwork");
this.currentTimeEl = this.container.querySelector(".time-current");
this.totalTimeEl = this.container.querySelector(".time-total");
this.bpmEl = this.container.querySelector(".waveform-bpm");
this.bpmValueEl = this.container.querySelector(".bpm-value");
this.loadingEl = this.container.querySelector(".waveform-loading");
this.errorEl = this.container.querySelector(".waveform-error");
this.markersContainer = this.container.querySelector(".waveform-markers");
this.speedBtn = this.container.querySelector(".speed-btn");
this.speedMenu = this.container.querySelector(".speed-menu");
this.resizeCanvas();
}
/**
* Create audio element
* @private
*/
createAudio() {
this.audio = new Audio();
this.audio.preload = this.options.preload || "metadata";
this.audio.crossOrigin = "anonymous";
}
// ============================================
// Feature Initialization
// ============================================
/**
* Initialize playback speed controls
* @private
*/
initPlaybackSpeed() {
if (this.options.playbackRate && this.options.playbackRate !== 1) {
this.audio.playbackRate = this.options.playbackRate;
}
if (this.options.showPlaybackSpeed) {
this.initSpeedControls();
}
}
/**
* Initialize speed control UI
* @private
*/
initSpeedControls() {
const speedBtn = this.container.querySelector(".speed-btn");
const speedMenu = this.container.querySelector(".speed-menu");
if (!speedBtn || !speedMenu) return;
speedBtn.addEventListener("click", (e) => {
e.stopPropagation();
speedMenu.style.display = speedMenu.style.display === "none" ? "block" : "none";
});
document.addEventListener("click", () => {
speedMenu.style.display = "none";
});
speedMenu.addEventListener("click", (e) => {
e.stopPropagation();
if (e.target.classList.contains("speed-option")) {
const rate = parseFloat(e.target.dataset.rate);
this.setPlaybackRate(rate);
speedMenu.style.display = "none";
}
});
this.updateSpeedUI();
}
/**
* Initialize keyboard controls
* @private
*/
initKeyboardControls() {
this.container.setAttribute("tabindex", "-1");
this.container.addEventListener("click", () => {
_WaveformPlayer.getAllInstances().forEach((player) => {
if (player !== this) {
player.container.setAttribute("tabindex", "-1");
}
});
this.container.setAttribute("tabindex", "0");
this.container.focus();
});
this.container.addEventListener("keydown", (e) => {
if (document.activeElement !== this.container) return;
const key = e.key;
const currentTime = this.audio.currentTime;
if (key >= "0" && key <= "9") {
e.preventDefault();
this.seekToPercent(parseInt(key) / 10);
return;
}
const actions = {
" ": () => this.togglePlay(),
"ArrowLeft": () => this.seekTo(Math.max(0, currentTime - 5)),
"ArrowRight": () => this.seekTo(Math.min(this.audio.duration, currentTime + 5)),
"ArrowUp": () => this.setVolume(Math.min(1, this.audio.volume + 0.1)),
"ArrowDown": () => this.setVolume(Math.max(0, this.audio.volume - 0.1)),
"m": () => this.audio.muted = !this.audio.muted,
"M": () => this.audio.muted = !this.audio.muted
};
if (actions[key]) {
e.preventDefault();
actions[key]();
}
});
}
/**
* Initialize Media Session API for system media controls
* @private
*/
initMediaSession() {
if (!("mediaSession" in navigator) || !this.options.enableMediaSession) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: this.options.title || "Unknown Track",
artist: this.options.subtitle || "",
album: this.options.album || "",
artwork: this.options.artwork ? [
{ src: this.options.artwork, sizes: "512x512", type: "image/jpeg" }
] : []
});
navigator.mediaSession.setActionHandler("play", () => this.play());
navigator.mediaSession.setActionHandler("pause", () => this.pause());
navigator.mediaSession.setActionHandler("seekbackward", () => {
this.seekTo(Math.max(0, this.audio.currentTime - 10));
});
navigator.mediaSession.setActionHandler("seekforward", () => {
this.seekTo(Math.min(this.audio.duration, this.audio.currentTime + 10));
});
navigator.mediaSession.setActionHandler("seekto", (details) => {
if (details.seekTime !== null) {
this.seekTo(details.seekTime);
}
});
}
// ============================================
// Event Binding
// ============================================
/**
* Bind event listeners
* @private
*/
bindEvents() {
this.playBtn.addEventListener("click", () => this.togglePlay());
this.audio.addEventListener("loadstart", () => this.setLoading(true));
this.audio.addEventListener("loadedmetadata", () => this.onMetadataLoaded());
this.audio.addEventListener("canplay", () => this.setLoading(false));
this.audio.addEventListener("play", () => this.onPlay());
this.audio.addEventListener("pause", () => this.onPause());
this.audio.addEventListener("ended", () => this.onEnded());
this.audio.addEventListener("error", (e) => this.onError(e));
this.canvas.addEventListener("click", (e) => this.handleCanvasClick(e));
window.addEventListener("resize", debounce(() => this.resizeCanvas(), 100));
}
/**
* Setup resize observer
* @private
*/
setupResizeObserver() {
if ("ResizeObserver" in window) {
this.resizeObserver = new ResizeObserver(() => {
this.resizeCanvas();
});
if (this.canvas?.parentElement) {
this.resizeObserver.observe(this.canvas.parentElement);
}
}
}
// ============================================
// Audio Loading
// ============================================
/**
* Load audio file
* @param {string} url - Audio URL
* @returns {Promise<void>}
*/
async load(url) {
try {
this.setLoading(true);
this.progress = 0;
this.hasError = false;
this.audio.src = url;
await new Promise((resolve, reject) => {
const metadataHandler = () => {
this.audio.removeEventListener("loadedmetadata", metadataHandler);
this.audio.removeEventListener("error", errorHandler);
resolve();
};
const errorHandler = (e) => {
this.audio.removeEventListener("loadedmetadata", metadataHandler);
this.audio.removeEventListener("error", errorHandler);
reject(e);
};
this.audio.addEventListener("loadedmetadata", metadataHandler);
this.audio.addEventListener("error", errorHandler);
});
const title = this.options.title || extractTitleFromUrl(url);
if (this.titleEl) {
this.titleEl.textContent = title;
}
if (this.options.waveform) {
this.setWaveformData(this.options.waveform);
} else {
try {
const result = await generateWaveform(url, this.options.samples, this.options.showBPM);
this.waveformData = result.peaks;
if (result.bpm) {
this.detectedBPM = result.bpm;
this.updateBPMDisplay();
}
} catch (error) {
console.warn("Using placeholder waveform:", error);
this.waveformData = generatePlaceholderWaveform(this.options.samples);
}
}
this.drawWaveform();
this.renderMarkers();
this.initMediaSession();
if (this.options.onLoad) {
this.options.onLoad(this);
}
} catch (error) {
console.error("Failed to load audio:", error);
this.onError(error);
} finally {
this.setLoading(false);
}
}
/**
* Load a new track
* @param {string} url - Audio URL
* @param {string} [title] - Track title
* @param {string} [subtitle] - Track subtitle
* @param {Object} [options] - Additional options
* @returns {Promise<void>}
*/
async loadTrack(url, title = null, subtitle = null, options = {}) {
if (this.isPlaying) {
this.pause();
}
this.audio.src = "";
this.audio.load();
this.hasError = false;
if (this.errorEl) {
this.errorEl.style.display = "none";
}
if (this.canvas) {
this.canvas.style.opacity = "1";
}
if (this.playBtn) {
this.playBtn.disabled = false;
}
this.progress = 0;
this.waveformData = [];
this.options = mergeOptions(this.options, {
url,
title: title || this.options.title,
subtitle: subtitle || this.options.subtitle,
...options
});
if (options.preload) {
this.audio.preload = options.preload;
}
if (this.subtitleEl) {
if (subtitle) {
this.subtitleEl.textContent = subtitle;
this.subtitleEl.style.display = "";
} else if (subtitle === "") {
this.subtitleEl.style.display = "none";
}
}
if (options.artwork && this.artworkEl) {
this.artworkEl.src = options.artwork;
}
if (options.markers) {
this.options.markers = options.markers;
}
await this.load(url);
this.play();
}
// ============================================
// Visualization
// ============================================
/**
* Set waveform data
* @private
*/
setWaveformData(data) {
if (typeof data === "string") {
try {
const parsed = JSON.parse(data);
this.waveformData = Array.isArray(parsed) ? parsed : [];
} catch {
this.waveformData = data.split(",").map(Number);
}
} else {
this.waveformData = Array.isArray(data) ? data : [];
}
this.drawWaveform();
}
/**
* Draw waveform
* @private
*/
drawWaveform() {
if (!this.ctx || this.waveformData.length === 0) return;
draw(this.ctx, this.canvas, this.waveformData, this.progress, {
...this.options,
waveformStyle: this.options.waveformStyle || "bars",
color: this.options.waveformColor,
progressColor: this.options.progressColor
});
}
/**
* Resize canvas
* @private
*/
resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * dpr;
this.canvas.height = this.options.height * dpr;
this.canvas.style.height = this.options.height + "px";
this.canvas.parentElement.style.height = this.options.height + "px";
this.drawWaveform();
}
/**
* Render markers on the waveform
* @private
*/
renderMarkers() {
if (!this.options.showMarkers || !this.options.markers?.length || !this.markersContainer) return;
this.markersContainer.innerHTML = "";
if (!this.audio || !this.audio.duration || this.audio.duration === 0) {
return;
}
this.options.markers.forEach((marker, index) => {
if (marker.time > this.audio.duration) {
console.warn(`Marker "${marker.label}" at ${marker.time}s exceeds audio duration of ${this.audio.duration}s`);
return;
}
const position = marker.time / this.audio.duration * 100;
const markerEl = document.createElement("button");
markerEl.className = "waveform-marker";
markerEl.style.left = `${position}%`;
markerEl.style.backgroundColor = marker.color || "rgba(255, 255, 255, 0.5)";
markerEl.setAttribute("aria-label", marker.label);
markerEl.setAttribute("data-time", marker.time);
const tooltip = document.createElement("span");
tooltip.className = "waveform-marker-tooltip";
tooltip.textContent = marker.label;
markerEl.appendChild(tooltip);
markerEl.addEventListener("click", (e) => {
e.stopPropagation();
this.seekTo(marker.time);
if (this.options.playOnSeek && !this.isPlaying) {
this.play();
}
});
this.markersContainer.appendChild(markerEl);
});
}
// ============================================
// Event Handlers
// ============================================
/**
* Handle canvas click
* @private
*/
handleCanvasClick(event) {
if (!this.audio.duration) return;
const rect = this.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const targetPercent = Math.max(0, Math.min(1, x / rect.width));
this.seekToPercent(targetPercent);
}
/**
* Set loading state
* @private
*/
setLoading(loading) {
this.isLoading = loading;
if (this.loadingEl) {
this.loadingEl.style.display = loading ? "block" : "none";
}
}
/**
* Handle metadata loaded
* @private
*/
onMetadataLoaded() {
if (this.totalTimeEl) {
this.totalTimeEl.textContent = formatTime(this.audio.duration);
}
this.renderMarkers();
}
/**
* Handle play event
* @private
*/
onPlay() {
this.isPlaying = true;
this.playBtn.classList.add("playing");
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
if (playIcon) playIcon.style.display = "none";
if (pauseIcon) pauseIcon.style.display = "flex";
this.startSmoothUpdate();
this.container.dispatchEvent(new CustomEvent("waveformplayer:play", {
detail: { player: this, url: this.options.url }
}));
if (this.options.onPlay) {
this.options.onPlay(this);
}
}
/**
* Handle pause event
* @private
*/
onPause() {
this.isPlaying = false;
this.playBtn.classList.remove("playing");
const playIcon = this.playBtn.querySelector(".waveform-icon-play");
const pauseIcon = this.playBtn.querySelector(".waveform-icon-pause");
if (playIcon) playIcon.style.display = "flex";
if (pauseIcon) pauseIcon.style.display = "none";
this.stopSmoothUpdate();
this.container.dispatchEvent(new CustomEvent("waveformplayer:pause", {
detail: { player: this, url: this.options.url }
}));
if (this.options.onPause) {
this.options.onPause(this);
}
}
/**
* Handle ended event
* @private
*/
onEnded() {
this.progress = 0;
this.audio.currentTime = 0;
this.drawWaveform();
if (this.currentTimeEl) {
this.currentTimeEl.textContent = "0:00";
}
this.container.dispatchEvent(new CustomEvent("waveformplayer:ended", {
detail: { player: this, url: this.options.url }
}));
this.onPause();
if (this.options.onEnd) {
this.options.onEnd(this);
}
}
/**
* Handle error event
* @private
*/
onError(error) {
if (this.isDestroying) return;
console.error("Audio error:", error);
this.hasError = true;
this.setLoading(false);
if (this.errorEl) {
this.errorEl.style.display = "flex";
}
if (this.canvas) {
this.canvas.style.opacity = "0.2";
}
if (this.playBtn) {
this.playBtn.disabled = true;
}
if (this.options.onError) {
this.options.onError(error, this);
}
}
// ============================================
// Progress Updates
// ============================================
/**
* Start smooth update animation
* @private
*/
startSmoothUpdate() {
this.stopSmoothUpdate();
const update = () => {
if (this.isPlaying && this.audio.duration) {
this.updateProgress();
this.updateTimer = requestAnimationFrame(update);
}
};
this.updateTimer = requestAnimationFrame(update);
}
/**
* Stop smooth update animation
* @private
*/
stopSmoothUpdate() {
if (this.updateTimer) {
cancelAnimationFrame(this.updateTimer);
this.updateTimer = null;
}
}
/**
* Update progress
* @private
*/
updateProgress() {
if (!this.audio.duration) return;
const newProgress = this.audio.currentTime / this.audio.duration;
if (Math.abs(newProgress - this.progress) > 1e-3) {
this.progress = newProgress;
this.drawWaveform();
}
if (this.currentTimeEl) {
this.currentTimeEl.textContent = formatTime(this.audio.currentTime);
}
this.container.dispatchEvent(new CustomEvent("waveformplayer:timeupdate", {
detail: {
player: this,
currentTime: this.audio.currentTime,
duration: this.audio.duration,
url: this.options.url
}
}));
if (this.options.onTimeUpdate) {
this.options.onTimeUpdate(this.audio.currentTime, this.audio.duration, this);
}
}
// ============================================
// UI Updates
// ============================================
/**
* Update BPM display
* @private
*/
updateBPMDisplay() {
if (this.bpmEl && this.bpmValueEl && this.detectedBPM) {
this.bpmValueEl.textContent = Math.round(this.detectedBPM);
this.bpmEl.style.display = "inline-flex";
}
}
/**
* Update speed UI to reflect current rate
* @private
*/
updateSpeedUI() {
const speedValue = this.container.querySelector(".speed-value");
if (speedValue) {
const rate = this.audio.playbackRate;
speedValue.textContent = rate === 1 ? "1x" : `${rate}x`;
}
this.container.querySelectorAll(".speed-option").forEach((btn) => {
btn.classList.toggle("active", parseFloat(btn.dataset.rate) === this.audio.playbackRate);
});
}
// ============================================
// Public API
// ============================================
/**
* Play audio
*/
play() {
if (this.options.singlePlay && _WaveformPlayer.currentlyPlaying && _WaveformPlayer.currentlyPlaying !== this) {
_WaveformPlayer.currentlyPlaying.pause();
}
_WaveformPlayer.currentlyPlaying = this;
this.audio.play();
}
/**
* Pause audio
*/
pause() {
if (_WaveformPlayer.currentlyPlaying === this) {
_WaveformPlayer.currentlyPlaying = null;
}
this.audio.pause();
}
/**
* Toggle play/pause
*/
togglePlay() {
if (this.isPlaying) {
this.pause();
} else {
this.play();
}
}
/**
* Seek to time in seconds
* @param {number} seconds - Time in seconds
*/
seekTo(seconds) {
if (this.