UNPKG

wavesurfer.js

Version:
633 lines (631 loc) 25.7 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; import EventEmitter from './event-emitter.js'; import * as utils from './renderer-utils.js'; import { createDragStream } from './reactive/drag-stream.js'; import { createScrollStream } from './reactive/scroll-stream.js'; import { effect } from './reactive/store.js'; class Renderer extends EventEmitter { constructor(options, audioElement) { super(); this.timeouts = []; this.isScrollable = false; this.audioData = null; this.resizeObserver = null; this.lastContainerWidth = 0; this.isDragging = false; this.subscriptions = []; this.unsubscribeOnScroll = []; this.dragStream = null; this.scrollStream = null; this.subscriptions = []; this.options = options; const parent = this.parentFromOptionsContainer(options.container); this.parent = parent; const [div, shadow] = this.initHtml(); parent.appendChild(div); this.container = div; this.scrollContainer = shadow.querySelector('.scroll'); this.wrapper = shadow.querySelector('.wrapper'); this.canvasWrapper = shadow.querySelector('.canvases'); this.progressWrapper = shadow.querySelector('.progress'); this.cursor = shadow.querySelector('.cursor'); if (audioElement) { shadow.appendChild(audioElement); } this.initEvents(); } parentFromOptionsContainer(container) { let parent; if (typeof container === 'string') { parent = document.querySelector(container); } else if (container instanceof HTMLElement) { parent = container; } if (!parent) { throw new Error('Container not found'); } return parent; } initEvents() { // Add a click listener this.wrapper.addEventListener('click', (e) => { const rect = this.wrapper.getBoundingClientRect(); const [x, y] = utils.getRelativePointerPosition(rect, e.clientX, e.clientY); this.emit('click', x, y); }); // Add a double click listener this.wrapper.addEventListener('dblclick', (e) => { const rect = this.wrapper.getBoundingClientRect(); const [x, y] = utils.getRelativePointerPosition(rect, e.clientX, e.clientY); this.emit('dblclick', x, y); }); // Drag if (this.options.dragToSeek === true || typeof this.options.dragToSeek === 'object') { this.initDrag(); } // Add a scroll listener using reactive stream this.scrollStream = createScrollStream(this.scrollContainer); const unsubscribeScroll = effect(() => { const { startX, endX } = this.scrollStream.percentages.value; const { left, right } = this.scrollStream.bounds.value; this.emit('scroll', startX, endX, left, right); }, [this.scrollStream.percentages, this.scrollStream.bounds]); this.subscriptions.push(unsubscribeScroll); // Re-render the waveform on container resize if (typeof ResizeObserver === 'function') { const delay = this.createDelay(100); this.resizeObserver = new ResizeObserver(() => { delay() .then(() => this.onContainerResize()) .catch(() => undefined); }); this.resizeObserver.observe(this.scrollContainer); } } onContainerResize() { const width = this.parent.clientWidth; if (width === this.lastContainerWidth && this.options.height !== 'auto') return; this.lastContainerWidth = width; this.reRender(); this.emit('resize'); } initDrag() { // Don't initialize drag if it's already set up if (this.dragStream) return; this.dragStream = createDragStream(this.wrapper); const unsubscribeDrag = effect(() => { const drag = this.dragStream.signal.value; if (!drag) return; const width = this.wrapper.getBoundingClientRect().width; const relX = utils.clampToUnit(drag.x / width); if (drag.type === 'start') { this.isDragging = true; this.emit('dragstart', relX); } else if (drag.type === 'move') { this.emit('drag', relX); } else if (drag.type === 'end') { this.isDragging = false; this.emit('dragend', relX); } }, [this.dragStream.signal]); this.subscriptions.push(unsubscribeDrag); } initHtml() { const div = document.createElement('div'); const shadow = div.attachShadow({ mode: 'open' }); const cspNonce = this.options.cspNonce && typeof this.options.cspNonce === 'string' ? this.options.cspNonce.replace(/"/g, '') : ''; shadow.innerHTML = ` <style${cspNonce ? ` nonce="${cspNonce}"` : ''}> :host { user-select: none; min-width: 1px; } :host audio { display: block; width: 100%; } :host .scroll { overflow-x: auto; overflow-y: hidden; width: 100%; position: relative; } :host .noScrollbar { scrollbar-color: transparent; scrollbar-width: none; } :host .noScrollbar::-webkit-scrollbar { display: none; -webkit-appearance: none; } :host .wrapper { position: relative; overflow: visible; z-index: 2; } :host .canvases { min-height: ${this.getHeight(this.options.height, this.options.splitChannels)}px; pointer-events: none; } :host .canvases > div { position: relative; } :host canvas { display: block; position: absolute; top: 0; image-rendering: pixelated; } :host .progress { pointer-events: none; position: absolute; z-index: 2; top: 0; left: 0; width: 0; height: 100%; overflow: hidden; } :host .progress > div { position: relative; } :host .cursor { pointer-events: none; position: absolute; z-index: 5; top: 0; left: 0; height: 100%; border-radius: 2px; } </style> <div class="scroll" part="scroll"> <div class="wrapper" part="wrapper"> <div class="canvases" part="canvases"></div> <div class="progress" part="progress"></div> <div class="cursor" part="cursor"></div> </div> </div> `; return [div, shadow]; } /** Wavesurfer itself calls this method. Do not call it manually. */ setOptions(options) { if (this.options.container !== options.container) { const newParent = this.parentFromOptionsContainer(options.container); newParent.appendChild(this.container); this.parent = newParent; } if (options.dragToSeek === true || typeof this.options.dragToSeek === 'object') { this.initDrag(); } this.options = options; // Re-render the waveform this.reRender(); } getWrapper() { return this.wrapper; } getWidth() { return this.scrollContainer.clientWidth; } getScroll() { return this.scrollContainer.scrollLeft; } setScroll(pixels) { this.scrollContainer.scrollLeft = pixels; } setScrollPercentage(percent) { const { scrollWidth } = this.scrollContainer; const scrollStart = scrollWidth * percent; this.setScroll(scrollStart); } destroy() { var _a; this.subscriptions.forEach((unsubscribe) => unsubscribe()); this.container.remove(); if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } (_a = this.unsubscribeOnScroll) === null || _a === void 0 ? void 0 : _a.forEach((unsubscribe) => unsubscribe()); this.unsubscribeOnScroll = []; if (this.dragStream) { this.dragStream.cleanup(); this.dragStream = null; } if (this.scrollStream) { this.scrollStream.cleanup(); this.scrollStream = null; } } createDelay(delayMs = 10) { let timeout; let rejectFn; const onClear = () => { if (timeout) { clearTimeout(timeout); timeout = undefined; } if (rejectFn) { rejectFn(); rejectFn = undefined; } }; this.timeouts.push(onClear); return () => { return new Promise((resolve, reject) => { // Clear any pending delay onClear(); // Store reject function for cleanup rejectFn = reject; // Set new timeout timeout = setTimeout(() => { timeout = undefined; rejectFn = undefined; resolve(); }, delayMs); }); }; } getHeight(optionsHeight, optionsSplitChannel) { var _a; const numberOfChannels = ((_a = this.audioData) === null || _a === void 0 ? void 0 : _a.numberOfChannels) || 1; return utils.resolveChannelHeight({ optionsHeight, optionsSplitChannels: optionsSplitChannel, parentHeight: this.parent.clientHeight, numberOfChannels, defaultHeight: utils.DEFAULT_HEIGHT, }); } convertColorValues(color, ctx) { return utils.resolveColorValue(color, this.getPixelRatio(), ctx === null || ctx === void 0 ? void 0 : ctx.canvas.height); } getPixelRatio() { return utils.getPixelRatio(window.devicePixelRatio); } renderBarWaveform(channelData, options, ctx, vScale) { const { width, height } = ctx.canvas; const { halfHeight, barWidth, barRadius, barIndexScale, barSpacing, barMinHeight } = utils.calculateBarRenderConfig({ width, height, length: (channelData[0] || []).length, options, pixelRatio: this.getPixelRatio(), }); const segments = utils.calculateBarSegments({ channelData, barIndexScale, barSpacing, barWidth, halfHeight, vScale, canvasHeight: height, barAlign: options.barAlign, barMinHeight, }); ctx.beginPath(); for (const segment of segments) { if (barRadius && 'roundRect' in ctx) { ; ctx.roundRect(segment.x, segment.y, segment.width, segment.height, barRadius); } else { ctx.rect(segment.x, segment.y, segment.width, segment.height); } } ctx.fill(); ctx.closePath(); } renderLineWaveform(channelData, _options, ctx, vScale) { const { width, height } = ctx.canvas; const paths = utils.calculateLinePaths({ channelData, width, height, vScale }); ctx.beginPath(); for (const path of paths) { if (!path.length) continue; ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) { const point = path[i]; ctx.lineTo(point.x, point.y); } } ctx.fill(); ctx.closePath(); } renderWaveform(channelData, options, ctx) { ctx.fillStyle = this.convertColorValues(options.waveColor, ctx); if (options.renderFunction) { options.renderFunction(channelData, ctx); return; } const vScale = utils.calculateVerticalScale({ channelData, barHeight: options.barHeight, normalize: options.normalize, maxPeak: options.maxPeak, }); if (utils.shouldRenderBars(options)) { this.renderBarWaveform(channelData, options, ctx, vScale); return; } this.renderLineWaveform(channelData, options, ctx, vScale); } renderSingleCanvas(data, options, width, height, offset, canvasContainer, progressContainer) { const pixelRatio = this.getPixelRatio(); const canvas = document.createElement('canvas'); canvas.width = Math.round(width * pixelRatio); canvas.height = Math.round(height * pixelRatio); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; canvas.style.left = `${Math.round(offset)}px`; canvasContainer.appendChild(canvas); const ctx = canvas.getContext('2d'); if (options.renderFunction) { ctx.fillStyle = this.convertColorValues(options.waveColor, ctx); options.renderFunction(data, ctx); } else { this.renderWaveform(data, options, ctx); } // Draw a progress canvas if (canvas.width > 0 && canvas.height > 0) { const progressCanvas = canvas.cloneNode(); const progressCtx = progressCanvas.getContext('2d'); progressCtx.drawImage(canvas, 0, 0); // Set the composition method to draw only where the waveform is drawn progressCtx.globalCompositeOperation = 'source-in'; progressCtx.fillStyle = this.convertColorValues(options.progressColor, progressCtx); // This rectangle acts as a mask thanks to the composition method progressCtx.fillRect(0, 0, canvas.width, canvas.height); progressContainer.appendChild(progressCanvas); } } renderMultiCanvas(channelData, options, width, height, canvasContainer, progressContainer) { const pixelRatio = this.getPixelRatio(); const { clientWidth } = this.scrollContainer; const totalWidth = width / pixelRatio; const singleCanvasWidth = utils.calculateSingleCanvasWidth({ clientWidth, totalWidth, options }); let drawnIndexes = {}; // Nothing to render if (singleCanvasWidth === 0) return; // Draw a single canvas const draw = (index) => { if (index < 0 || index >= numCanvases) return; if (drawnIndexes[index]) return; drawnIndexes[index] = true; const offset = index * singleCanvasWidth; let clampedWidth = Math.min(totalWidth - offset, singleCanvasWidth); // Clamp the width to the bar grid to avoid empty canvases at the end clampedWidth = utils.clampWidthToBarGrid(clampedWidth, options); if (clampedWidth <= 0) return; const data = utils.sliceChannelData({ channelData, offset, clampedWidth, totalWidth }); this.renderSingleCanvas(data, options, clampedWidth, height, offset, canvasContainer, progressContainer); }; // Clear canvases to avoid too many DOM nodes const clearCanvases = () => { if (utils.shouldClearCanvases(Object.keys(drawnIndexes).length)) { canvasContainer.innerHTML = ''; progressContainer.innerHTML = ''; drawnIndexes = {}; } }; // Calculate how many canvases to render const numCanvases = Math.ceil(totalWidth / singleCanvasWidth); // Render all canvases if the waveform doesn't scroll if (!this.isScrollable) { for (let i = 0; i < numCanvases; i++) { draw(i); } return; } // Lazy rendering const initialRange = utils.getLazyRenderRange({ scrollLeft: this.scrollContainer.scrollLeft, totalWidth, numCanvases, }); initialRange.forEach((index) => draw(index)); // Subscribe to the scroll event to draw additional canvases if (numCanvases > 1) { const unsubscribe = this.on('scroll', () => { const { scrollLeft } = this.scrollContainer; clearCanvases(); utils.getLazyRenderRange({ scrollLeft, totalWidth, numCanvases }).forEach((index) => draw(index)); }); this.unsubscribeOnScroll.push(unsubscribe); } } renderChannel(channelData, _a, width, channelIndex) { var { overlay } = _a, options = __rest(_a, ["overlay"]); // A container for canvases const canvasContainer = document.createElement('div'); const height = this.getHeight(options.height, options.splitChannels); canvasContainer.style.height = `${height}px`; if (overlay && channelIndex > 0) { canvasContainer.style.marginTop = `-${height}px`; } this.canvasWrapper.style.minHeight = `${height}px`; this.canvasWrapper.appendChild(canvasContainer); // A container for progress canvases const progressContainer = canvasContainer.cloneNode(); this.progressWrapper.appendChild(progressContainer); // Render the waveform this.renderMultiCanvas(channelData, options, width, height, canvasContainer, progressContainer); } render(audioData) { return __awaiter(this, void 0, void 0, function* () { var _a; // Clear previous timeouts this.timeouts.forEach((clear) => clear()); this.timeouts = []; // Clear the canvases this.canvasWrapper.innerHTML = ''; this.progressWrapper.innerHTML = ''; // Width if (this.options.width != null) { this.scrollContainer.style.width = typeof this.options.width === 'number' ? `${this.options.width}px` : this.options.width; } // Determine the width of the waveform const pixelRatio = this.getPixelRatio(); const parentWidth = this.scrollContainer.clientWidth; const { scrollWidth, isScrollable, useParentWidth, width } = utils.calculateWaveformLayout({ duration: audioData.duration, minPxPerSec: this.options.minPxPerSec || 0, parentWidth, fillParent: this.options.fillParent, pixelRatio, }); // Whether the container should scroll this.isScrollable = isScrollable; // Set the width of the wrapper this.wrapper.style.width = useParentWidth ? '100%' : `${scrollWidth}px`; // Set additional styles this.scrollContainer.style.overflowX = this.isScrollable ? 'auto' : 'hidden'; this.scrollContainer.classList.toggle('noScrollbar', !!this.options.hideScrollbar); this.cursor.style.backgroundColor = `${this.options.cursorColor || this.options.progressColor}`; this.cursor.style.width = `${this.options.cursorWidth}px`; this.audioData = audioData; this.emit('render'); // Render the waveform if (this.options.splitChannels) { // Render a waveform for each channel for (let i = 0; i < audioData.numberOfChannels; i++) { const options = Object.assign(Object.assign({}, this.options), (_a = this.options.splitChannels) === null || _a === void 0 ? void 0 : _a[i]); this.renderChannel([audioData.getChannelData(i)], options, width, i); } } else { // Render a single waveform for the first two channels (left and right) const channels = [audioData.getChannelData(0)]; if (audioData.numberOfChannels > 1) channels.push(audioData.getChannelData(1)); this.renderChannel(channels, this.options, width, 0); } // Must be emitted asynchronously for backward compatibility Promise.resolve().then(() => this.emit('rendered')); }); } reRender() { this.unsubscribeOnScroll.forEach((unsubscribe) => unsubscribe()); this.unsubscribeOnScroll = []; // Return if the waveform has not been rendered yet if (!this.audioData) return; // Remember the current cursor position const { scrollWidth } = this.scrollContainer; const { right: before } = this.progressWrapper.getBoundingClientRect(); // Re-render the waveform this.render(this.audioData); // Adjust the scroll position so that the cursor stays in the same place if (this.isScrollable && scrollWidth !== this.scrollContainer.scrollWidth) { const { right: after } = this.progressWrapper.getBoundingClientRect(); const delta = utils.roundToHalfAwayFromZero(after - before); this.scrollContainer.scrollLeft += delta; } } zoom(minPxPerSec) { this.options.minPxPerSec = minPxPerSec; this.reRender(); } scrollIntoView(progress, isPlaying = false) { const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer; const progressWidth = progress * scrollWidth; const startEdge = scrollLeft; const endEdge = scrollLeft + clientWidth; const middle = clientWidth / 2; if (this.isDragging) { // Scroll when dragging close to the edge of the viewport const minGap = 30; if (progressWidth + minGap > endEdge) { this.scrollContainer.scrollLeft += minGap; } else if (progressWidth - minGap < startEdge) { this.scrollContainer.scrollLeft -= minGap; } } else { if (progressWidth < startEdge || progressWidth > endEdge) { this.scrollContainer.scrollLeft = progressWidth - (this.options.autoCenter ? middle : 0); } // Keep the cursor centered when playing const center = progressWidth - scrollLeft - middle; if (isPlaying && this.options.autoCenter && center > 0) { this.scrollContainer.scrollLeft += center; } } } renderProgress(progress, isPlaying) { if (isNaN(progress)) return; const percents = progress * 100; this.canvasWrapper.style.clipPath = `polygon(${percents}% 0%, 100% 0%, 100% 100%, ${percents}% 100%)`; this.progressWrapper.style.width = `${percents}%`; this.cursor.style.left = `${percents}%`; this.cursor.style.transform = this.options.cursorWidth ? `translateX(-${progress * this.options.cursorWidth}px)` : ''; // Only scroll if we have valid audio data to prevent race conditions during loading if (this.isScrollable && this.options.autoScroll && this.audioData && this.audioData.duration > 0) { this.scrollIntoView(progress, isPlaying); } } exportImage(format, quality, type) { return __awaiter(this, void 0, void 0, function* () { const canvases = this.canvasWrapper.querySelectorAll('canvas'); if (!canvases.length) { throw new Error('No waveform data'); } // Data URLs if (type === 'dataURL') { const images = Array.from(canvases).map((canvas) => canvas.toDataURL(format, quality)); return Promise.resolve(images); } // Blobs return Promise.all(Array.from(canvases).map((canvas) => { return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) { resolve(blob); } else { reject(new Error('Could not export image')); } }, format, quality); }); })); }); } } export default Renderer;