wavesurfer.js 7.0.0-alpha.2 → 7.0.0-alpha.21
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/README.md +13 -15
- package/dist/base-plugin.d.ts +6 -4
- package/dist/base-plugin.js +19 -0
- package/dist/decoder.d.ts +2 -4
- package/dist/decoder.js +31 -0
- package/dist/event-emitter.d.ts +1 -1
- package/dist/event-emitter.js +26 -0
- package/dist/fetcher.js +6 -0
- package/dist/index.d.ts +66 -20
- package/dist/index.js +238 -0
- package/dist/player.d.ts +13 -2
- package/dist/player.js +90 -0
- package/dist/plugins/envelope.d.ts +58 -0
- package/dist/plugins/envelope.js +292 -0
- package/dist/plugins/minimap.d.ts +25 -0
- package/dist/plugins/minimap.js +63 -0
- package/dist/plugins/multitrack.d.ts +111 -0
- package/dist/plugins/multitrack.js +489 -0
- package/dist/plugins/regions.d.ts +14 -9
- package/dist/plugins/regions.js +231 -0
- package/dist/plugins/timeline.d.ts +36 -0
- package/dist/plugins/timeline.js +125 -0
- package/dist/renderer.d.ts +16 -10
- package/dist/renderer.js +239 -0
- package/dist/timer.d.ts +2 -1
- package/dist/timer.js +19 -0
- package/dist/wavesurfer.Minimap.min.js +1 -0
- package/dist/wavesurfer.Multitrack.min.js +1 -0
- package/dist/wavesurfer.Regions.min.js +1 -1
- package/dist/wavesurfer.Timeline.min.js +1 -0
- package/dist/wavesurfer.min.js +1 -1
- package/package.json +32 -20
- package/dist/player-webaudio.d.ts +0 -8
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import BasePlugin from '../base-plugin.js';
|
|
2
|
+
const MIN_WIDTH = 10;
|
|
3
|
+
const defaultOptions = {
|
|
4
|
+
dragSelection: true,
|
|
5
|
+
draggable: true,
|
|
6
|
+
resizable: true,
|
|
7
|
+
};
|
|
8
|
+
const style = (element, styles) => {
|
|
9
|
+
for (const key in styles) {
|
|
10
|
+
element.style[key] = styles[key] || '';
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const el = (tagName, css) => {
|
|
14
|
+
const element = document.createElement(tagName);
|
|
15
|
+
style(element, css);
|
|
16
|
+
return element;
|
|
17
|
+
};
|
|
18
|
+
class RegionsPlugin extends BasePlugin {
|
|
19
|
+
dragStart = NaN;
|
|
20
|
+
regionsContainer;
|
|
21
|
+
regions = [];
|
|
22
|
+
createdRegion = null;
|
|
23
|
+
modifiedRegion = null;
|
|
24
|
+
isResizingLeft = false;
|
|
25
|
+
isMoving = false;
|
|
26
|
+
/** Create an instance of RegionsPlugin */
|
|
27
|
+
constructor(params, options) {
|
|
28
|
+
super(params, options);
|
|
29
|
+
this.options = Object.assign({}, defaultOptions, options);
|
|
30
|
+
this.regionsContainer = this.initRegionsContainer();
|
|
31
|
+
this.subscriptions.push(this.wavesurfer.once('decode', () => {
|
|
32
|
+
this.wrapper.appendChild(this.regionsContainer);
|
|
33
|
+
}));
|
|
34
|
+
this.wrapper.addEventListener('mousedown', this.handleMouseDown);
|
|
35
|
+
document.addEventListener('mousemove', this.handleMouseMove);
|
|
36
|
+
document.addEventListener('mouseup', this.handleMouseUp);
|
|
37
|
+
}
|
|
38
|
+
/** Unmount */
|
|
39
|
+
destroy() {
|
|
40
|
+
this.wrapper.removeEventListener('mousedown', this.handleMouseDown);
|
|
41
|
+
document.removeEventListener('mousemove', this.handleMouseMove);
|
|
42
|
+
document.removeEventListener('mouseup', this.handleMouseUp, true);
|
|
43
|
+
this.regionsContainer.remove();
|
|
44
|
+
super.destroy();
|
|
45
|
+
}
|
|
46
|
+
initRegionsContainer() {
|
|
47
|
+
return el('div', {
|
|
48
|
+
position: 'absolute',
|
|
49
|
+
top: '0',
|
|
50
|
+
left: '0',
|
|
51
|
+
width: '100%',
|
|
52
|
+
height: '100%',
|
|
53
|
+
zIndex: '3',
|
|
54
|
+
pointerEvents: 'none',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
handleMouseDown = (e) => {
|
|
58
|
+
if (this.options.draggable || this.options.resizable || this.options.dragSelection) {
|
|
59
|
+
this.dragStart = e.clientX - this.wrapper.getBoundingClientRect().left;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
handleMouseMove = (e) => {
|
|
63
|
+
const box = this.wrapper.getBoundingClientRect();
|
|
64
|
+
const { width } = box;
|
|
65
|
+
const dragEnd = e.clientX - box.left;
|
|
66
|
+
if (this.options.draggable && this.modifiedRegion && this.isMoving) {
|
|
67
|
+
this.moveRegion(this.modifiedRegion, (dragEnd - this.dragStart) / width);
|
|
68
|
+
this.dragStart = dragEnd;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (this.options.resizable && this.modifiedRegion) {
|
|
72
|
+
this.updateRegion(this.modifiedRegion, this.isResizingLeft ? dragEnd / width : undefined, this.isResizingLeft ? undefined : dragEnd / width);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (this.options.dragSelection && !isNaN(this.dragStart)) {
|
|
76
|
+
const dragEnd = e.clientX - this.regionsContainer.getBoundingClientRect().left;
|
|
77
|
+
if (dragEnd - this.dragStart >= MIN_WIDTH) {
|
|
78
|
+
if (!this.createdRegion) {
|
|
79
|
+
this.wrapper.style.pointerEvents = 'none';
|
|
80
|
+
this.createdRegion = this.createRegion(this.dragStart / width, dragEnd / width);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
this.updateRegion(this.createdRegion, this.dragStart / width, dragEnd / width);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
handleMouseUp = () => {
|
|
89
|
+
if (this.createdRegion) {
|
|
90
|
+
this.addRegion(this.createdRegion);
|
|
91
|
+
this.createdRegion = null;
|
|
92
|
+
}
|
|
93
|
+
this.modifiedRegion = null;
|
|
94
|
+
this.isMoving = false;
|
|
95
|
+
this.dragStart = NaN;
|
|
96
|
+
this.wrapper.style.pointerEvents = '';
|
|
97
|
+
};
|
|
98
|
+
createRegionElement(start, end, title = '') {
|
|
99
|
+
const noWidth = start === end;
|
|
100
|
+
const div = el('div', {
|
|
101
|
+
position: 'absolute',
|
|
102
|
+
left: `${start * 100}%`,
|
|
103
|
+
width: `${(end - start) * 100}%`,
|
|
104
|
+
height: '100%',
|
|
105
|
+
backgroundColor: noWidth ? '' : 'rgba(0, 0, 0, 0.1)',
|
|
106
|
+
borderRadius: '2px',
|
|
107
|
+
boxSizing: 'border-box',
|
|
108
|
+
borderLeft: noWidth ? '2px solid rgba(0, 0, 0, 0.5)' : '',
|
|
109
|
+
transition: 'background-color 0.2s ease',
|
|
110
|
+
cursor: this.options.draggable ? 'move' : '',
|
|
111
|
+
pointerEvents: 'all',
|
|
112
|
+
whiteSpace: noWidth ? 'nowrap' : '',
|
|
113
|
+
padding: '0.2em',
|
|
114
|
+
});
|
|
115
|
+
div.textContent = title;
|
|
116
|
+
const leftHandle = el('div', {
|
|
117
|
+
position: 'absolute',
|
|
118
|
+
left: '0',
|
|
119
|
+
top: '0',
|
|
120
|
+
width: '6px',
|
|
121
|
+
height: '100%',
|
|
122
|
+
cursor: this.options.resizable ? 'ew-resize' : '',
|
|
123
|
+
pointerEvents: 'all',
|
|
124
|
+
borderLeft: noWidth ? '' : '2px solid rgba(0, 0, 0, 0.5)',
|
|
125
|
+
borderRadius: '2px 0 0 2px',
|
|
126
|
+
});
|
|
127
|
+
div.appendChild(leftHandle);
|
|
128
|
+
const rightHandle = leftHandle.cloneNode();
|
|
129
|
+
style(rightHandle, {
|
|
130
|
+
left: '',
|
|
131
|
+
right: '0',
|
|
132
|
+
borderLeft: '',
|
|
133
|
+
borderRight: noWidth ? '' : '2px solid rgba(0, 0, 0, 0.5)',
|
|
134
|
+
borderRadius: '0 2px 2px 0',
|
|
135
|
+
});
|
|
136
|
+
div.appendChild(rightHandle);
|
|
137
|
+
leftHandle.addEventListener('mousedown', (e) => {
|
|
138
|
+
if (!this.options.resizable)
|
|
139
|
+
return;
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
this.modifiedRegion = this.regions.find((r) => r.element === div) || null;
|
|
142
|
+
this.isResizingLeft = true;
|
|
143
|
+
this.isMoving = false;
|
|
144
|
+
});
|
|
145
|
+
rightHandle.addEventListener('mousedown', (e) => {
|
|
146
|
+
if (!this.options.resizable)
|
|
147
|
+
return;
|
|
148
|
+
e.stopPropagation();
|
|
149
|
+
this.modifiedRegion = this.regions.find((r) => r.element === div) || null;
|
|
150
|
+
this.isResizingLeft = false;
|
|
151
|
+
this.isMoving = false;
|
|
152
|
+
});
|
|
153
|
+
div.addEventListener('mousedown', () => {
|
|
154
|
+
if (!this.options.draggable)
|
|
155
|
+
return;
|
|
156
|
+
this.modifiedRegion = this.regions.find((r) => r.element === div) || null;
|
|
157
|
+
this.isMoving = true;
|
|
158
|
+
});
|
|
159
|
+
div.addEventListener('click', () => {
|
|
160
|
+
const region = this.regions.find((r) => r.element === div);
|
|
161
|
+
if (region) {
|
|
162
|
+
this.emit('region-clicked', { region });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
this.regionsContainer.appendChild(div);
|
|
166
|
+
// Check that the label doesn't overlap with other labels
|
|
167
|
+
// If it does, push it down
|
|
168
|
+
const labelLeft = div.getBoundingClientRect().left;
|
|
169
|
+
const labelWidth = div.scrollWidth;
|
|
170
|
+
const overlap = this.regions
|
|
171
|
+
.filter((reg) => {
|
|
172
|
+
const { left } = reg.element.getBoundingClientRect();
|
|
173
|
+
const width = reg.element.scrollWidth;
|
|
174
|
+
return labelLeft < left + width && left < labelLeft + labelWidth;
|
|
175
|
+
})
|
|
176
|
+
.map((reg) => parseFloat(reg.element.style.paddingTop))
|
|
177
|
+
.reduce((sum, val) => sum + val, 0);
|
|
178
|
+
if (overlap > 0) {
|
|
179
|
+
div.style.paddingTop = `${overlap + 1}em`;
|
|
180
|
+
}
|
|
181
|
+
return div;
|
|
182
|
+
}
|
|
183
|
+
createRegion(start, end, title = '') {
|
|
184
|
+
const duration = this.wavesurfer.getDuration();
|
|
185
|
+
return {
|
|
186
|
+
element: this.createRegionElement(start, end, title),
|
|
187
|
+
start,
|
|
188
|
+
end,
|
|
189
|
+
startTime: start * duration,
|
|
190
|
+
endTime: end * duration,
|
|
191
|
+
title,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
addRegion(region) {
|
|
195
|
+
this.regions.push(region);
|
|
196
|
+
this.emit('region-created', { region });
|
|
197
|
+
}
|
|
198
|
+
updateRegion(region, start, end) {
|
|
199
|
+
if (start != null) {
|
|
200
|
+
region.start = start;
|
|
201
|
+
region.element.style.left = `${region.start * 100}%`;
|
|
202
|
+
region.element.style.width = `${(region.end - region.start) * 100}%`;
|
|
203
|
+
region.startTime = start * this.wavesurfer.getDuration();
|
|
204
|
+
}
|
|
205
|
+
if (end != null) {
|
|
206
|
+
region.end = end;
|
|
207
|
+
region.element.style.width = `${(region.end - region.start) * 100}%`;
|
|
208
|
+
region.endTime = end * this.wavesurfer.getDuration();
|
|
209
|
+
}
|
|
210
|
+
this.emit('region-updated', { region });
|
|
211
|
+
}
|
|
212
|
+
moveRegion(region, delta) {
|
|
213
|
+
this.updateRegion(region, region.start + delta, region.end + delta);
|
|
214
|
+
}
|
|
215
|
+
/** Create a region at a given start and end time, with an optional title */
|
|
216
|
+
add(startTime, endTime, title = '', color = '') {
|
|
217
|
+
const duration = this.wavesurfer.getDuration();
|
|
218
|
+
const start = startTime / duration;
|
|
219
|
+
const end = endTime / duration;
|
|
220
|
+
const region = this.createRegion(start, end, title);
|
|
221
|
+
this.addRegion(region);
|
|
222
|
+
if (color)
|
|
223
|
+
this.setRegionColor(region, color);
|
|
224
|
+
return region;
|
|
225
|
+
}
|
|
226
|
+
/** Set the background color of a region */
|
|
227
|
+
setRegionColor(region, color) {
|
|
228
|
+
region.element.style[region.startTime === region.endTime ? 'borderColor' : 'backgroundColor'] = color;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export default RegionsPlugin;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import BasePlugin from '../base-plugin.js';
|
|
2
|
+
import type { WaveSurferPluginParams } from '../index.js';
|
|
3
|
+
export type TimelinePluginOptions = {
|
|
4
|
+
/** The height of the timeline in pixels, defaults to 20 */
|
|
5
|
+
height?: number;
|
|
6
|
+
/** HTML container for the timeline, defaults to wavesufer's container */
|
|
7
|
+
container?: HTMLElement;
|
|
8
|
+
/** The duration of the timeline in seconds, defaults to wavesurfer's duration */
|
|
9
|
+
duration?: number;
|
|
10
|
+
/** Interval between ticks in seconds */
|
|
11
|
+
timeInterval?: number;
|
|
12
|
+
/** Interval between numeric labels */
|
|
13
|
+
primaryLabelInterval?: number;
|
|
14
|
+
/** Interval between secondary numeric labels */
|
|
15
|
+
secondaryLabelInterval?: number;
|
|
16
|
+
};
|
|
17
|
+
declare const defaultOptions: {
|
|
18
|
+
height: number;
|
|
19
|
+
};
|
|
20
|
+
export type TimelinePluginEvents = {
|
|
21
|
+
ready: void;
|
|
22
|
+
};
|
|
23
|
+
declare class TimelinePlugin extends BasePlugin<TimelinePluginEvents, TimelinePluginOptions> {
|
|
24
|
+
private timelineWrapper;
|
|
25
|
+
protected options: TimelinePluginOptions & typeof defaultOptions;
|
|
26
|
+
constructor(params: WaveSurferPluginParams, options: TimelinePluginOptions);
|
|
27
|
+
/** Unmount */
|
|
28
|
+
destroy(): void;
|
|
29
|
+
private initTimelineWrapper;
|
|
30
|
+
private formatTime;
|
|
31
|
+
private defaultTimeInterval;
|
|
32
|
+
defaultPrimaryLabelInterval(pxPerSec: number): number;
|
|
33
|
+
defaultSecondaryLabelInterval(pxPerSec: number): number;
|
|
34
|
+
private initTimeline;
|
|
35
|
+
}
|
|
36
|
+
export default TimelinePlugin;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import BasePlugin from '../base-plugin.js';
|
|
2
|
+
const defaultOptions = {
|
|
3
|
+
height: 20,
|
|
4
|
+
};
|
|
5
|
+
class TimelinePlugin extends BasePlugin {
|
|
6
|
+
timelineWrapper;
|
|
7
|
+
options;
|
|
8
|
+
constructor(params, options) {
|
|
9
|
+
super(params, options);
|
|
10
|
+
this.options = Object.assign({}, defaultOptions, options);
|
|
11
|
+
this.container = this.options.container ?? this.wrapper;
|
|
12
|
+
this.timelineWrapper = this.initTimelineWrapper();
|
|
13
|
+
this.container.appendChild(this.timelineWrapper);
|
|
14
|
+
if (this.options.duration) {
|
|
15
|
+
this.initTimeline(this.options.duration);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
this.subscriptions.push(this.wavesurfer.on('decode', ({ duration }) => {
|
|
19
|
+
this.initTimeline(duration);
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Unmount */
|
|
24
|
+
destroy() {
|
|
25
|
+
this.timelineWrapper.remove();
|
|
26
|
+
super.destroy();
|
|
27
|
+
}
|
|
28
|
+
initTimelineWrapper() {
|
|
29
|
+
return document.createElement('div');
|
|
30
|
+
}
|
|
31
|
+
formatTime(seconds) {
|
|
32
|
+
if (seconds / 60 > 1) {
|
|
33
|
+
// calculate minutes and seconds from seconds count
|
|
34
|
+
const minutes = Math.round(seconds / 60);
|
|
35
|
+
seconds = Math.round(seconds % 60);
|
|
36
|
+
const paddedSeconds = `${seconds < 10 ? '0' : ''}${seconds}`;
|
|
37
|
+
return `${minutes}:${paddedSeconds}`;
|
|
38
|
+
}
|
|
39
|
+
const rounded = Math.round(seconds * 1000) / 1000;
|
|
40
|
+
return `${rounded}`;
|
|
41
|
+
}
|
|
42
|
+
// Return how many seconds should be between each notch
|
|
43
|
+
defaultTimeInterval(pxPerSec) {
|
|
44
|
+
if (pxPerSec >= 25) {
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
else if (pxPerSec * 5 >= 25) {
|
|
48
|
+
return 5;
|
|
49
|
+
}
|
|
50
|
+
else if (pxPerSec * 15 >= 25) {
|
|
51
|
+
return 15;
|
|
52
|
+
}
|
|
53
|
+
return Math.ceil(0.5 / pxPerSec) * 60;
|
|
54
|
+
}
|
|
55
|
+
// Return the cadence of notches that get labels in the primary color.
|
|
56
|
+
defaultPrimaryLabelInterval(pxPerSec) {
|
|
57
|
+
if (pxPerSec >= 25) {
|
|
58
|
+
return 10;
|
|
59
|
+
}
|
|
60
|
+
else if (pxPerSec * 5 >= 25) {
|
|
61
|
+
return 6;
|
|
62
|
+
}
|
|
63
|
+
else if (pxPerSec * 15 >= 25) {
|
|
64
|
+
return 4;
|
|
65
|
+
}
|
|
66
|
+
return 4;
|
|
67
|
+
}
|
|
68
|
+
// Return the cadence of notches that get labels in the secondary color.
|
|
69
|
+
defaultSecondaryLabelInterval(pxPerSec) {
|
|
70
|
+
if (pxPerSec >= 25) {
|
|
71
|
+
return 5;
|
|
72
|
+
}
|
|
73
|
+
else if (pxPerSec * 5 >= 25) {
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
else if (pxPerSec * 15 >= 25) {
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
initTimeline(duration) {
|
|
82
|
+
const width = Math.round(this.timelineWrapper.scrollWidth * devicePixelRatio);
|
|
83
|
+
const pxPerSec = width / duration;
|
|
84
|
+
const timeInterval = this.options.timeInterval ?? this.defaultTimeInterval(pxPerSec);
|
|
85
|
+
const primaryLabelInterval = this.options.primaryLabelInterval ?? this.defaultPrimaryLabelInterval(pxPerSec);
|
|
86
|
+
const secondaryLabelInterval = this.options.secondaryLabelInterval ?? this.defaultSecondaryLabelInterval(pxPerSec);
|
|
87
|
+
const timeline = document.createElement('div');
|
|
88
|
+
timeline.setAttribute('style', `
|
|
89
|
+
height: ${this.options.height}px;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
display: flex;
|
|
92
|
+
justify-content: space-between;
|
|
93
|
+
align-items: flex-end;
|
|
94
|
+
font-size: ${this.options.height / 2}px;
|
|
95
|
+
white-space: nowrap;
|
|
96
|
+
`);
|
|
97
|
+
const notchEl = document.createElement('div');
|
|
98
|
+
notchEl.setAttribute('style', `
|
|
99
|
+
width: 1px;
|
|
100
|
+
height: 50%;
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
justify-content: flex-end;
|
|
104
|
+
overflow: visible;
|
|
105
|
+
border-left: 1px solid currentColor;
|
|
106
|
+
opacity: 0.25;
|
|
107
|
+
`);
|
|
108
|
+
for (let i = 0; i < duration; i += timeInterval) {
|
|
109
|
+
const notch = notchEl.cloneNode();
|
|
110
|
+
const isPrimary = i % primaryLabelInterval === 0;
|
|
111
|
+
const isSecondary = i % secondaryLabelInterval === 0;
|
|
112
|
+
if (isPrimary || isSecondary) {
|
|
113
|
+
notch.style.height = '100%';
|
|
114
|
+
notch.style.textIndent = '3px';
|
|
115
|
+
notch.textContent = this.formatTime(i);
|
|
116
|
+
if (isPrimary)
|
|
117
|
+
notch.style.opacity = '1';
|
|
118
|
+
}
|
|
119
|
+
timeline.appendChild(notch);
|
|
120
|
+
}
|
|
121
|
+
this.timelineWrapper.appendChild(timeline);
|
|
122
|
+
this.emit('ready');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export default TimelinePlugin;
|
package/dist/renderer.d.ts
CHANGED
|
@@ -4,10 +4,14 @@ type RendererOptions = {
|
|
|
4
4
|
height: number;
|
|
5
5
|
waveColor: string;
|
|
6
6
|
progressColor: string;
|
|
7
|
+
cursorColor?: string;
|
|
8
|
+
cursorWidth: number;
|
|
7
9
|
minPxPerSec: number;
|
|
10
|
+
fillParent: boolean;
|
|
8
11
|
barWidth?: number;
|
|
9
12
|
barGap?: number;
|
|
10
13
|
barRadius?: number;
|
|
14
|
+
noScrollbar?: boolean;
|
|
11
15
|
};
|
|
12
16
|
type RendererEvents = {
|
|
13
17
|
click: {
|
|
@@ -17,18 +21,20 @@ type RendererEvents = {
|
|
|
17
21
|
declare class Renderer extends EventEmitter<RendererEvents> {
|
|
18
22
|
private options;
|
|
19
23
|
private container;
|
|
20
|
-
private
|
|
21
|
-
private
|
|
22
|
-
private
|
|
23
|
-
private
|
|
24
|
-
private
|
|
24
|
+
private scrollContainer;
|
|
25
|
+
private wrapper;
|
|
26
|
+
private canvasWrapper;
|
|
27
|
+
private progressWrapper;
|
|
28
|
+
private timeout;
|
|
29
|
+
private isScrolling;
|
|
25
30
|
constructor(options: RendererOptions);
|
|
31
|
+
getContainer(): HTMLElement;
|
|
32
|
+
getWrapper(): HTMLElement;
|
|
26
33
|
destroy(): void;
|
|
27
|
-
private
|
|
28
|
-
private
|
|
29
|
-
render(
|
|
30
|
-
|
|
34
|
+
private delay;
|
|
35
|
+
private renderPeaks;
|
|
36
|
+
render(audioData: AudioBuffer): void;
|
|
37
|
+
zoom(audioData: AudioBuffer, minPxPerSec: number): void;
|
|
31
38
|
renderProgress(progress: number, autoCenter?: boolean): void;
|
|
32
|
-
getContainer(): HTMLElement;
|
|
33
39
|
}
|
|
34
40
|
export default Renderer;
|
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import EventEmitter from './event-emitter.js';
|
|
2
|
+
class Renderer extends EventEmitter {
|
|
3
|
+
options;
|
|
4
|
+
container;
|
|
5
|
+
scrollContainer;
|
|
6
|
+
wrapper;
|
|
7
|
+
canvasWrapper;
|
|
8
|
+
progressWrapper;
|
|
9
|
+
timeout = null;
|
|
10
|
+
isScrolling = false;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
super();
|
|
13
|
+
this.options = options;
|
|
14
|
+
let container = null;
|
|
15
|
+
if (typeof this.options.container === 'string') {
|
|
16
|
+
container = document.querySelector(this.options.container);
|
|
17
|
+
}
|
|
18
|
+
else if (this.options.container instanceof HTMLElement) {
|
|
19
|
+
container = this.options.container;
|
|
20
|
+
}
|
|
21
|
+
if (!container) {
|
|
22
|
+
throw new Error('Container not found');
|
|
23
|
+
}
|
|
24
|
+
const div = document.createElement('div');
|
|
25
|
+
const shadow = div.attachShadow({ mode: 'open' });
|
|
26
|
+
shadow.innerHTML = `
|
|
27
|
+
<style>
|
|
28
|
+
:host {
|
|
29
|
+
user-select: none;
|
|
30
|
+
}
|
|
31
|
+
:host .scroll {
|
|
32
|
+
overflow-x: auto;
|
|
33
|
+
overflow-y: hidden;
|
|
34
|
+
width: 100%;
|
|
35
|
+
position: relative;
|
|
36
|
+
${this.options.noScrollbar ? 'scrollbar-color: transparent;' : ''}
|
|
37
|
+
}
|
|
38
|
+
:host ::-webkit-scrollbar {
|
|
39
|
+
display: ${this.options.noScrollbar ? 'none' : 'auto'};
|
|
40
|
+
}
|
|
41
|
+
:host .wrapper {
|
|
42
|
+
position: relative;
|
|
43
|
+
min-width: 100%;
|
|
44
|
+
z-index: 2;
|
|
45
|
+
}
|
|
46
|
+
:host .canvases {
|
|
47
|
+
position: relative;
|
|
48
|
+
height: ${this.options.height}px;
|
|
49
|
+
}
|
|
50
|
+
:host canvas {
|
|
51
|
+
display: block;
|
|
52
|
+
position: absolute;
|
|
53
|
+
top: 0;
|
|
54
|
+
height: ${this.options.height}px;
|
|
55
|
+
image-rendering: pixelated;
|
|
56
|
+
}
|
|
57
|
+
:host .progress {
|
|
58
|
+
pointer-events: none;
|
|
59
|
+
position: absolute;
|
|
60
|
+
z-index: 2;
|
|
61
|
+
top: 0;
|
|
62
|
+
left: 0;
|
|
63
|
+
width: 0;
|
|
64
|
+
height: ${this.options.height}px;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
box-sizing: border-box;
|
|
67
|
+
border-right-style: solid;
|
|
68
|
+
border-right-width: ${this.options.cursorWidth}px;
|
|
69
|
+
border-right-color: ${this.options.cursorColor || this.options.progressColor};
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
72
|
+
|
|
73
|
+
<div class="scroll">
|
|
74
|
+
<div class="wrapper">
|
|
75
|
+
<div class="canvases"></div>
|
|
76
|
+
<div class="progress"></div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
`;
|
|
80
|
+
this.container = div;
|
|
81
|
+
this.scrollContainer = shadow.querySelector('.scroll');
|
|
82
|
+
this.wrapper = shadow.querySelector('.wrapper');
|
|
83
|
+
this.canvasWrapper = shadow.querySelector('.canvases');
|
|
84
|
+
this.progressWrapper = shadow.querySelector('.progress');
|
|
85
|
+
container.appendChild(div);
|
|
86
|
+
this.wrapper.addEventListener('click', (e) => {
|
|
87
|
+
const rect = this.wrapper.getBoundingClientRect();
|
|
88
|
+
const x = e.clientX - rect.left;
|
|
89
|
+
const relativeX = x / rect.width;
|
|
90
|
+
this.emit('click', { relativeX });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
getContainer() {
|
|
94
|
+
return this.scrollContainer;
|
|
95
|
+
}
|
|
96
|
+
getWrapper() {
|
|
97
|
+
return this.wrapper;
|
|
98
|
+
}
|
|
99
|
+
destroy() {
|
|
100
|
+
this.container.remove();
|
|
101
|
+
}
|
|
102
|
+
delay(fn, delayMs = 10) {
|
|
103
|
+
if (this.timeout) {
|
|
104
|
+
clearTimeout(this.timeout);
|
|
105
|
+
}
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
this.timeout = setTimeout(() => {
|
|
108
|
+
resolve(fn());
|
|
109
|
+
}, delayMs);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async renderPeaks(channelData, width, height, pixelRatio) {
|
|
113
|
+
const barWidth = this.options.barWidth != null ? this.options.barWidth * pixelRatio : 1;
|
|
114
|
+
const barGap = this.options.barGap != null ? this.options.barGap * pixelRatio : this.options.barWidth ? barWidth / 2 : 0;
|
|
115
|
+
const barRadius = this.options.barRadius ?? 0;
|
|
116
|
+
const leftChannel = channelData[0];
|
|
117
|
+
const len = leftChannel.length;
|
|
118
|
+
const barCount = Math.floor(width / (barWidth + barGap));
|
|
119
|
+
const barIndexScale = barCount / len;
|
|
120
|
+
const halfHeight = height / 2;
|
|
121
|
+
const isMono = channelData.length === 1;
|
|
122
|
+
const rightChannel = isMono ? leftChannel : channelData[1];
|
|
123
|
+
const useNegative = isMono && rightChannel.some((v) => v < 0);
|
|
124
|
+
const draw = (start, end) => {
|
|
125
|
+
let prevX = 0;
|
|
126
|
+
let prevLeft = 0;
|
|
127
|
+
let prevRight = 0;
|
|
128
|
+
const canvas = document.createElement('canvas');
|
|
129
|
+
canvas.width = Math.round((width * (end - start)) / len);
|
|
130
|
+
canvas.height = this.options.height;
|
|
131
|
+
canvas.style.width = `${Math.floor(canvas.width / pixelRatio)}px`;
|
|
132
|
+
canvas.style.left = `${Math.floor((start * width) / pixelRatio / len)}px`;
|
|
133
|
+
this.canvasWrapper.appendChild(canvas);
|
|
134
|
+
const ctx = canvas.getContext('2d', {
|
|
135
|
+
desynchronized: true,
|
|
136
|
+
});
|
|
137
|
+
ctx.beginPath();
|
|
138
|
+
ctx.fillStyle = this.options.waveColor;
|
|
139
|
+
// Firefox shim until 2023.04.11
|
|
140
|
+
if (!ctx.roundRect)
|
|
141
|
+
ctx.roundRect = ctx.fillRect;
|
|
142
|
+
for (let i = start; i < end; i++) {
|
|
143
|
+
const barIndex = Math.round((i - start) * barIndexScale);
|
|
144
|
+
if (barIndex > prevX) {
|
|
145
|
+
const leftBarHeight = Math.round(prevLeft * halfHeight);
|
|
146
|
+
const rightBarHeight = Math.round(prevRight * halfHeight);
|
|
147
|
+
ctx.roundRect(prevX * (barWidth + barGap), halfHeight - leftBarHeight, barWidth, leftBarHeight + rightBarHeight || 1, barRadius);
|
|
148
|
+
prevX = barIndex;
|
|
149
|
+
prevLeft = 0;
|
|
150
|
+
prevRight = 0;
|
|
151
|
+
}
|
|
152
|
+
const leftValue = useNegative ? leftChannel[i] : Math.abs(leftChannel[i]);
|
|
153
|
+
const rightValue = useNegative ? rightChannel[i] : Math.abs(rightChannel[i]);
|
|
154
|
+
if (leftValue > prevLeft) {
|
|
155
|
+
prevLeft = leftValue;
|
|
156
|
+
}
|
|
157
|
+
// If stereo, both channels are drawn as max values
|
|
158
|
+
// If mono with negative values, the bottom channel will be the min negative values
|
|
159
|
+
if (useNegative ? rightValue < -prevRight : rightValue > prevRight) {
|
|
160
|
+
prevRight = rightValue < 0 ? -rightValue : rightValue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
ctx.fill();
|
|
164
|
+
ctx.closePath();
|
|
165
|
+
// Draw a progress canvas
|
|
166
|
+
const progressCanvas = canvas.cloneNode();
|
|
167
|
+
this.progressWrapper.appendChild(progressCanvas);
|
|
168
|
+
const progressCtx = progressCanvas.getContext('2d', {
|
|
169
|
+
desynchronized: true,
|
|
170
|
+
});
|
|
171
|
+
progressCtx.drawImage(canvas, 0, 0);
|
|
172
|
+
// Set the composition method to draw only where the waveform is drawn
|
|
173
|
+
progressCtx.globalCompositeOperation = 'source-in';
|
|
174
|
+
progressCtx.fillStyle = this.options.progressColor;
|
|
175
|
+
// This rectangle acts as a mask thanks to the composition method
|
|
176
|
+
progressCtx.fillRect(0, 0, canvas.width, canvas.height);
|
|
177
|
+
};
|
|
178
|
+
// Clear the canvas
|
|
179
|
+
this.canvasWrapper.innerHTML = '';
|
|
180
|
+
this.progressWrapper.innerHTML = '';
|
|
181
|
+
// Draw the currently visible part of the waveform
|
|
182
|
+
const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer;
|
|
183
|
+
const scale = len / scrollWidth;
|
|
184
|
+
const start = Math.floor(scrollLeft * scale);
|
|
185
|
+
const end = Math.ceil(Math.min(scrollWidth, scrollLeft + clientWidth) * scale);
|
|
186
|
+
draw(start, end);
|
|
187
|
+
// Draw the rest of the waveform with a timeout for better performance
|
|
188
|
+
const step = end - start;
|
|
189
|
+
for (let i = end; i < len; i += step) {
|
|
190
|
+
await this.delay(() => {
|
|
191
|
+
draw(i, Math.min(len, i + step));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
for (let i = start - 1; i >= 0; i -= step) {
|
|
195
|
+
await this.delay(() => {
|
|
196
|
+
draw(Math.max(0, i - step), i);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
render(audioData) {
|
|
201
|
+
// Determine the width of the canvas
|
|
202
|
+
const pixelRatio = window.devicePixelRatio || 1;
|
|
203
|
+
const parentWidth = this.options.fillParent ? this.scrollContainer.clientWidth * pixelRatio : 0;
|
|
204
|
+
const scrollWidth = audioData.duration * this.options.minPxPerSec;
|
|
205
|
+
this.isScrolling = scrollWidth > parentWidth;
|
|
206
|
+
const width = Math.max(1, this.isScrolling ? scrollWidth : parentWidth);
|
|
207
|
+
const { height } = this.options;
|
|
208
|
+
// Remember the current cursor position
|
|
209
|
+
const oldCursorPosition = this.progressWrapper.clientWidth;
|
|
210
|
+
this.scrollContainer.style.overflowX = this.isScrolling ? 'auto' : 'hidden';
|
|
211
|
+
this.wrapper.style.width = `${Math.floor(width / pixelRatio)}px`;
|
|
212
|
+
// Adjust the scroll position so that the cursor stays in the same place
|
|
213
|
+
const newCursortPosition = this.progressWrapper.clientWidth;
|
|
214
|
+
this.scrollContainer.scrollLeft += newCursortPosition - oldCursorPosition;
|
|
215
|
+
// First two channels are used
|
|
216
|
+
const channelData = [audioData.getChannelData(0)];
|
|
217
|
+
if (audioData.numberOfChannels > 1) {
|
|
218
|
+
channelData.push(audioData.getChannelData(1));
|
|
219
|
+
}
|
|
220
|
+
this.renderPeaks(channelData, width, height, pixelRatio);
|
|
221
|
+
}
|
|
222
|
+
zoom(audioData, minPxPerSec) {
|
|
223
|
+
this.options.minPxPerSec = minPxPerSec;
|
|
224
|
+
this.render(audioData);
|
|
225
|
+
}
|
|
226
|
+
renderProgress(progress, autoCenter = false) {
|
|
227
|
+
this.progressWrapper.style.width = `${progress * 100}%`;
|
|
228
|
+
if (this.isScrolling) {
|
|
229
|
+
const containerWidth = this.scrollContainer.clientWidth;
|
|
230
|
+
const center = containerWidth / 2;
|
|
231
|
+
const progressWidth = this.progressWrapper.clientWidth;
|
|
232
|
+
const minScroll = autoCenter ? center : containerWidth;
|
|
233
|
+
if (progressWidth > this.scrollContainer.scrollLeft + minScroll) {
|
|
234
|
+
this.scrollContainer.scrollLeft = progressWidth - center;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export default Renderer;
|