UNPKG

wavesurfer-multitracks

Version:

A modification to the multi-track super-plugin for wavesurfer.js that allows each track to have a different container

529 lines (528 loc) 21.4 kB
/** * MultiTracks is a super-plugin for creating a MultiTrack audio player. * Individual tracks are synced and played together. * They can be dragged to set their start position. * The top track is meant for dragging'n'dropping an additional track id (not a file). */ import WaveSurfer from "wavesurfer.js"; import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.js"; import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline.js"; import EnvelopePlugin from "wavesurfer.js/dist/plugins/envelope.js"; import EventEmitter from "./event-emitter"; class MultiTracks extends EventEmitter { static create(tracks, options) { return new MultiTracks(tracks, options); } constructor(tracks, options) { super(); this.audios = []; this.wavesurfers = []; this.durations = []; this.currentTime = 0; this.maxDuration = 0; this.isDragging = false; this.frameRequest = null; this.timer = null; this.subscriptions = []; this.timeline = null; this.tracks = tracks.map((track) => ({ ...track, startPosition: track.startPosition || 0, peaks: track.peaks || (track.url ? undefined : [new Float32Array()]), })); this.options = options; this.rendering = initRendering(this.tracks, this.options); this.rendering.addDropHandler((trackId) => { this.emit("drop", { id: trackId }); }); this.initAllAudios().then((durations) => { this.initDurations(durations); this.initAllWavesurfers(); this.rendering.containers.forEach(({ container, wrapper }, index) => { const drag = initDragging(wrapper, (delta) => this.onDrag(index, delta), options.rightButtonDrag); this.wavesurfers[index].once("destroy", () => drag?.destroy()); // Click to seek container.addEventListener("click", (e) => { if (this.isDragging) return; // determine poisition for current track const rect = container.getBoundingClientRect(); const x = e.clientX - rect.left; const position = x / container.offsetWidth; const time = position * durations[index] + tracks[index].startPosition; this.seekTo(time); }); }); this.emit("canplay"); }); } initDurations(durations) { this.durations = durations; this.maxDuration = this.tracks.reduce((max, track, index) => { return Math.max(max, track.startPosition + durations[index]); }, 0); this.rendering.setMainWidth(durations, this.maxDuration); } initAudio(track) { const audio = new Audio(track.url); return new Promise((resolve) => { if (!audio.src) return resolve(audio); audio.addEventListener("loadedmetadata", () => resolve(audio), { once: true, }); }); } async initAllAudios() { this.audios = await Promise.all(this.tracks.map((track) => this.initAudio(track))); return this.audios.map((a) => (a.src ? a.duration : 0)); } initWavesurfer(track, index) { const { container } = this.rendering.containers[index]; // Create a wavesurfer instance const ws = WaveSurfer.create({ ...track.options, container, minPxPerSec: 0, media: this.audios[index], peaks: track.peaks, cursorColor: "transparent", cursorWidth: 0, interact: false, hideScrollbar: true, }); // Regions and markers const wsRegions = RegionsPlugin.create(); ws.registerPlugin(wsRegions); this.subscriptions.push(ws.once("decode", () => { // Start and end cues if (track.startCue != null || track.endCue != null) { const { startCue = 0, endCue = this.durations[index] } = track; const startCueRegion = wsRegions.addRegion({ start: 0, end: startCue, color: "rgba(0, 0, 0, 0.7)", drag: false, }); const endCueRegion = wsRegions.addRegion({ start: endCue, end: endCue + this.durations[index], color: "rgba(0, 0, 0, 0.7)", drag: false, }); // Allow resizing only from one side startCueRegion.element.firstElementChild?.remove(); endCueRegion.element.lastChild?.remove(); // Prevent clicks when dragging // Update the start and end cues on resize this.subscriptions.push(startCueRegion.on("update-end", () => { track.startCue = startCueRegion.end; this.emit("start-cue-change", { id: track.id, startCue: track.startCue, }); }), endCueRegion.on("update-end", () => { track.endCue = endCueRegion.start; this.emit("end-cue-change", { id: track.id, endCue: track.endCue, }); })); } // Intro if (track.intro) { const introRegion = wsRegions.addRegion({ start: 0, end: track.intro.endTime, content: track.intro.label, color: this.options.trackBackground, drag: false, }); introRegion.element.querySelector('[data-resize="left"]')?.remove(); introRegion.element.parentElement.style.mixBlendMode = "plus-lighter"; if (track.intro.color) { introRegion.element.querySelector('[data-resize="right"]').style.borderColor = track.intro.color; } this.subscriptions.push(introRegion.on("update-end", () => { this.emit("intro-end-change", { id: track.id, endTime: introRegion.end, }); })); } // Render markers if (track.markers) { track.markers.forEach((marker) => { wsRegions.addRegion({ start: marker.time, content: marker.label, color: marker.color, resize: false, }); }); } })); // Envelope const envelope = ws.registerPlugin(EnvelopePlugin.create({ ...this.options.envelopeOptions, fadeInStart: track.startCue, fadeInEnd: track.fadeInEnd, fadeOutStart: track.fadeOutStart, fadeOutEnd: track.endCue, volume: track.volume, })); this.subscriptions.push(envelope.on("volume-change", (volume) => { this.setIsDragging(); this.emit("volume-change", { id: track.id, volume }); }), envelope.on("fade-in-change", (time) => { this.setIsDragging(); this.emit("fade-in-change", { id: track.id, fadeInEnd: time }); }), envelope.on("fade-out-change", (time) => { this.setIsDragging(); this.emit("fade-out-change", { id: track.id, fadeOutStart: time }); }), this.on("start-cue-change", ({ id, startCue }) => { if (id === track.id) { envelope.setStartTime(startCue); } }), this.on("end-cue-change", ({ id, endCue }) => { if (id === track.id) { envelope.setEndTime(endCue); } })); return ws; } initAllWavesurfers() { const wavesurfers = this.tracks.map((track, index) => { return this.initWavesurfer(track, index); }); this.wavesurfers = wavesurfers; this.initTimeline(); } initTimeline() { if (this.timeline) this.timeline.destroy(); this.timeline = this.wavesurfers[0].registerPlugin(TimelinePlugin.create({ duration: this.maxDuration, container: this.rendering.containers[0].container.parentElement, })); } updatePosition(time, autoCenter = false) { const precisionSeconds = 0.3; const isPaused = !this.isPlaying(); if (time !== this.currentTime) { this.currentTime = time; this.rendering.containers.forEach((container, i) => { this.rendering.updateCursor(container, (time - this.tracks[i].startPosition) / this.durations[i], autoCenter); }); } // Update the current time of each audio this.tracks.forEach((track, index) => { const audio = this.audios[index]; const duration = this.durations[index]; const newTime = time - track.startPosition; if (Math.abs(audio.currentTime - newTime) > precisionSeconds) { audio.currentTime = newTime; } // If the position is out of the track bounds, pause it if (isPaused || newTime < 0 || newTime > duration) { !audio.paused && audio.pause(); } else if (!isPaused) { // If the position is in the track bounds, play it audio.paused && audio.play(); } // Unmute if cue is reached const newVolume = newTime >= (track.startCue || 0) && newTime < (track.endCue || Infinity) ? 1 : 0; if (newVolume !== audio.volume) audio.volume = newVolume; }); } setIsDragging() { // Prevent click events when dragging this.isDragging = true; if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(() => { this.isDragging = false; }, 300); } onDrag(index, delta) { this.setIsDragging(); const track = this.tracks[index]; if (!track.draggable) return; const newStartPosition = track.startPosition + delta * this.maxDuration; const mainIndex = this.tracks.findIndex((item) => item.url && !item.draggable); const mainTrack = this.tracks[mainIndex]; const minStart = (mainTrack ? mainTrack.startPosition : 0) - this.durations[index]; const maxStart = mainTrack ? mainTrack.startPosition + this.durations[mainIndex] : this.maxDuration; if (newStartPosition >= minStart && newStartPosition <= maxStart) { track.startPosition = newStartPosition; this.initDurations(this.durations); this.rendering.setContainerOffsets(); this.updatePosition(this.currentTime); this.emit("start-position-change", { id: track.id, startPosition: newStartPosition, }); } } findCurrentTracks() { // Find the audios at the current time const indexes = []; this.tracks.forEach((track, index) => { if (track.url && this.currentTime >= track.startPosition && this.currentTime < track.startPosition + this.durations[index]) { indexes.push(index); } }); if (indexes.length === 0) { const minStartTime = Math.min(...this.tracks.filter((t) => t.url).map((track) => track.startPosition)); indexes.push(this.tracks.findIndex((track) => track.startPosition === minStartTime)); } return indexes; } startSync() { const onFrame = () => { const syncTime = this.audios.reduce((pos, audio, index) => { let position = pos; if (!audio.paused) { position = Math.max(pos, audio.currentTime + this.tracks[index].startPosition); } return position; }, this.currentTime); if (syncTime > this.currentTime) { this.updatePosition(syncTime, true); } this.frameRequest = requestAnimationFrame(onFrame); }; onFrame(); } play() { this.startSync(); const indexes = this.findCurrentTracks(); indexes.forEach((index) => { this.audios[index]?.play(); }); } pause() { this.audios.forEach((audio) => audio.pause()); } isPlaying() { return this.audios.some((audio) => !audio.paused); } getCurrentTime() { return this.currentTime; } // Seek to absolute time for other tracks based on position of clicked track seekTo(time) { const wasPlaying = this.isPlaying(); this.wavesurfers.forEach(() => this.updatePosition(time)); if (wasPlaying) this.play(); } /** Set time in seconds */ setTime(time) { const wasPlaying = this.isPlaying(); this.updatePosition(time); if (wasPlaying) this.play(); } zoom(pxPerSec) { this.options.minPxPerSec = pxPerSec; this.wavesurfers.forEach((ws, index) => this.tracks[index].url && ws.zoom(pxPerSec)); this.rendering.setMainWidth(this.durations, this.maxDuration); } addTrack(track) { const index = this.tracks.findIndex((t) => t.id === track.id); if (index !== -1) { this.tracks[index] = track; this.initAudio(track).then((audio) => { this.audios[index] = audio; this.durations[index] = audio.duration; this.initDurations(this.durations); const { container } = this.rendering.containers[index]; container.innerHTML = ""; this.wavesurfers[index].destroy(); this.wavesurfers[index] = this.initWavesurfer(track, index); const drag = initDragging(container, (delta) => this.onDrag(index, delta), this.options.rightButtonDrag); this.wavesurfers[index].once("destroy", () => drag?.destroy()); this.initTimeline(); this.emit("canplay"); }); } } destroy() { if (this.frameRequest) cancelAnimationFrame(this.frameRequest); this.rendering.destroy(); this.audios.forEach((audio) => { audio.pause(); audio.src = ""; }); this.wavesurfers.forEach((ws) => { ws.destroy(); }); } // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId setSinkId(sinkId) { return Promise.all(this.wavesurfers.map((ws) => ws.setSinkId(sinkId))); } } function initRendering(tracks, options) { let pxPerSec = 0; let durations = []; let mainWidth = 0; const multiWrapper = options.container || document.body; // Create containers for each track const containers = tracks.map((track, index) => { const container = track.container || document.createElement("div"); // Create the scrollbar for each track const scroll = document.createElement("div"); scroll.setAttribute("style", `width: 100%; overflow-x: ${track.hideScrollbar ? "hidden" : "scroll"}; overflow-y: hidden; user-select: none; position: relative;`); const wrapper = document.createElement("div"); wrapper.style.position = "relative"; scroll.appendChild(wrapper); container.appendChild(scroll); // Create a cursor for each track const cursor = document.createElement("div"); cursor.setAttribute("style", "height: 100%; position: absolute; z-index: 10; top: 0; left: 0"); cursor.style.backgroundColor = options.cursorColor || "#000"; cursor.style.width = `${options.cursorWidth ?? 1}px`; container.appendChild(cursor); if (options.trackBorderColor && index > 0) { const borderDiv = document.createElement("div"); borderDiv.setAttribute("style", `width: 100%; height: 2px; background-color: ${options.trackBorderColor}`); wrapper.appendChild(borderDiv); } if (options.trackBackground && track.url) { container.style.background = options.trackBackground; } // No audio on this track, so make it droppable if (!track.url) { const dropArea = document.createElement("div"); dropArea.setAttribute("style", `position: absolute; z-index: 10; left: 10px; top: 10px; right: 10px; bottom: 10px; border: 2px dashed ${options.trackBorderColor};`); dropArea.addEventListener("dragover", (e) => { e.preventDefault(); dropArea.style.background = options.trackBackground || ""; }); dropArea.addEventListener("dragleave", (e) => { e.preventDefault(); dropArea.style.background = ""; }); dropArea.addEventListener("drop", (e) => { e.preventDefault(); dropArea.style.background = ""; }); container.appendChild(dropArea); } if (multiWrapper) multiWrapper.appendChild(container); return { container, scroll, cursor, wrapper }; }); // Set the positions of each container const setContainerOffsets = () => { containers.forEach(({ container }, i) => { const offset = tracks[i].startPosition * pxPerSec; if (durations[i]) { container.style.width = `${durations[i] * pxPerSec}px`; } container.style.transform = `translateX(${offset}px)`; }); }; return { containers, // Set the start offset setContainerOffsets, // Set the container width setMainWidth: (trackDurations, maxDuration) => { durations = trackDurations; durations.forEach((_, i) => { pxPerSec = Math.max(options.minPxPerSec || 0, containers[i].wrapper.clientWidth / maxDuration); mainWidth = pxPerSec * maxDuration; containers[i].container.style.width = `${mainWidth}px`; }); setContainerOffsets(); }, // Update cursor position updateCursor: ({ cursor, scroll }, position, autoCenter) => { cursor.style.left = `${Math.min(100, position * 100)}%`; // Update scroll const { clientWidth, scrollLeft } = scroll; const center = clientWidth / 2; const minScroll = autoCenter ? center : clientWidth; const pos = position * mainWidth; if (pos > scrollLeft + minScroll || pos < scrollLeft) { scroll.scrollLeft = pos - center; } }, // Destroy the container destroy: () => { containers.forEach(({ scroll }) => scroll.remove()); }, // Do something on drop addDropHandler: (onDrop) => { tracks.forEach((track, index) => { if (!track.url) { const droppable = containers[index].wrapper.querySelector("div"); droppable?.addEventListener("drop", (e) => { e.preventDefault(); onDrop(track.id); }); } }); }, }; } function initDragging(container, onDrag, rightButtonDrag = false) { const wrapper = container.parentElement; if (!wrapper) return; // Dragging tracks to set position let dragStart = null; container.addEventListener("contextmenu", (e) => { rightButtonDrag && e.preventDefault(); }); // Drag start container.addEventListener("mousedown", (e) => { if (rightButtonDrag && e.button !== 2) return; const rect = wrapper.getBoundingClientRect(); dragStart = e.clientX - rect.left; container.style.cursor = "grabbing"; }); // Drag end const onMouseUp = (e) => { if (dragStart != null) { e.stopPropagation(); dragStart = null; container.style.cursor = ""; } }; // Drag move const onMouseMove = (e) => { if (dragStart == null) return; const rect = wrapper.getBoundingClientRect(); const x = e.clientX - rect.left; const diff = x - dragStart; if (diff > 1 || diff < -1) { dragStart = x; onDrag(diff / wrapper.offsetWidth); } }; document.body.addEventListener("mouseup", onMouseUp); document.body.addEventListener("mousemove", onMouseMove); return { destroy: () => { document.body.removeEventListener("mouseup", onMouseUp); document.body.removeEventListener("mousemove", onMouseMove); }, }; } export default MultiTracks;