wavesurfer.js 7.12.7 → 7.12.8

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.
Files changed (71) hide show
  1. package/README.md +12 -6
  2. package/dist/__tests__/dom.test.js +17 -1
  3. package/dist/__tests__/drag-stream.test.d.ts +1 -0
  4. package/dist/__tests__/drag-stream.test.js +108 -0
  5. package/dist/__tests__/draggable.test.js +34 -0
  6. package/dist/__tests__/hover.test.js +35 -7
  7. package/dist/__tests__/regions.test.js +39 -0
  8. package/dist/__tests__/renderer.test.js +4 -0
  9. package/dist/__tests__/wavesurfer.test.js +13 -0
  10. package/dist/__tests__/webaudio.test.js +25 -0
  11. package/dist/dom.d.ts +5 -0
  12. package/dist/dom.js +12 -0
  13. package/dist/draggable.js +14 -4
  14. package/dist/fetcher.js +31 -10
  15. package/dist/fft.js +5 -1
  16. package/dist/player.d.ts +1 -0
  17. package/dist/player.js +13 -4
  18. package/dist/plugins/envelope.cjs +1 -1
  19. package/dist/plugins/envelope.esm.js +1 -1
  20. package/dist/plugins/envelope.js +1 -1
  21. package/dist/plugins/envelope.min.js +1 -1
  22. package/dist/plugins/hover.cjs +1 -1
  23. package/dist/plugins/hover.d.ts +2 -0
  24. package/dist/plugins/hover.esm.js +1 -1
  25. package/dist/plugins/hover.js +1 -1
  26. package/dist/plugins/hover.min.js +1 -1
  27. package/dist/plugins/minimap.cjs +1 -1
  28. package/dist/plugins/minimap.esm.js +1 -1
  29. package/dist/plugins/minimap.js +1 -1
  30. package/dist/plugins/minimap.min.js +1 -1
  31. package/dist/plugins/record.cjs +1 -1
  32. package/dist/plugins/record.esm.js +1 -1
  33. package/dist/plugins/record.js +1 -1
  34. package/dist/plugins/record.min.js +1 -1
  35. package/dist/plugins/regions.cjs +1 -1
  36. package/dist/plugins/regions.d.ts +1 -0
  37. package/dist/plugins/regions.esm.js +1 -1
  38. package/dist/plugins/regions.js +1 -1
  39. package/dist/plugins/regions.min.js +1 -1
  40. package/dist/plugins/spectrogram-windowed.cjs +1 -1
  41. package/dist/plugins/spectrogram-windowed.esm.js +1 -1
  42. package/dist/plugins/spectrogram-windowed.js +1 -1
  43. package/dist/plugins/spectrogram-windowed.min.js +1 -1
  44. package/dist/plugins/spectrogram.cjs +1 -1
  45. package/dist/plugins/spectrogram.esm.js +1 -1
  46. package/dist/plugins/spectrogram.js +1 -1
  47. package/dist/plugins/spectrogram.min.js +1 -1
  48. package/dist/plugins/timeline.cjs +1 -1
  49. package/dist/plugins/timeline.esm.js +1 -1
  50. package/dist/plugins/timeline.js +1 -1
  51. package/dist/plugins/timeline.min.js +1 -1
  52. package/dist/plugins/zoom.cjs +1 -1
  53. package/dist/plugins/zoom.d.ts +1 -0
  54. package/dist/plugins/zoom.esm.js +1 -1
  55. package/dist/plugins/zoom.js +1 -1
  56. package/dist/plugins/zoom.min.js +1 -1
  57. package/dist/reactive/drag-stream.js +14 -4
  58. package/dist/reactive/state-event-emitter.js +7 -0
  59. package/dist/renderer-utils.js +4 -2
  60. package/dist/renderer.d.ts +2 -0
  61. package/dist/renderer.js +23 -11
  62. package/dist/timer.js +1 -0
  63. package/dist/types.d.ts +18 -2
  64. package/dist/wavesurfer.cjs +1 -1
  65. package/dist/wavesurfer.d.ts +2 -0
  66. package/dist/wavesurfer.esm.js +1 -1
  67. package/dist/wavesurfer.js +35 -6
  68. package/dist/wavesurfer.min.js +1 -1
  69. package/dist/webaudio.d.ts +5 -0
  70. package/dist/webaudio.js +54 -3
  71. package/package.json +15 -14
package/README.md CHANGED
@@ -6,7 +6,9 @@
6
6
 
7
7
  <img width="626" alt="waveform screenshot" src="https://github.com/katspaugh/wavesurfer.js/assets/381895/05f03bed-800e-4fa1-b09a-82a39a1c62ce">
8
8
 
9
- **Gold sponsor 💖** [Closed Caption Creator](https://www.closedcaptioncreator.com)
9
+ ---
10
+ ### Gold Sponsor 💖 [Closed Caption Creator](https://www.closedcaptioncreator.com) – Professional Subtitle Editor
11
+ ---
10
12
 
11
13
  # Table of contents
12
14
 
@@ -35,7 +37,7 @@ Alternatively, insert a UMD script tag which exports the library as a global `Wa
35
37
  <script src="https://unpkg.com/wavesurfer.js@7"></script>
36
38
  ```
37
39
 
38
- Create a wavesurfer instance and pass various [options](http://wavesurfer.xyz/docs/options):
40
+ Create a wavesurfer instance and pass various [options](https://wavesurfer.xyz/docs/api/options):
39
41
  ```js
40
42
  const wavesurfer = WaveSurfer.create({
41
43
  container: '#waveform',
@@ -59,13 +61,17 @@ TypeScript types are included in the package, so there's no need to install `@ty
59
61
 
60
62
  See more [examples](https://wavesurfer.xyz/examples).
61
63
 
64
+ ## Documentation
65
+
66
+ See the [wavesurfer.js guide](https://wavesurfer.xyz/docs/) on our website for beginner-friendly, practical documentation covering common use cases, plugins, framework integration, and troubleshooting.
67
+
62
68
  ## API reference
63
69
 
64
- See the wavesurfer.js documentation on our website:
70
+ The full generated API reference lives at [wavesurfer.xyz/docs/api](https://wavesurfer.xyz/docs/api/):
65
71
 
66
- * [methods](https://wavesurfer.xyz/docs/methods)
67
- * [options](http://wavesurfer.xyz/docs/options)
68
- * [events](http://wavesurfer.xyz/docs/events)
72
+ * [methods](https://wavesurfer.xyz/docs/api/methods)
73
+ * [options](https://wavesurfer.xyz/docs/api/options)
74
+ * [events](https://wavesurfer.xyz/docs/api/events)
69
75
 
70
76
  ## Plugins
71
77
 
@@ -1,4 +1,20 @@
1
- import createElement from '../dom.js';
1
+ import createElement, { isHTMLElement } from '../dom.js';
2
+ describe('isHTMLElement', () => {
3
+ test('accepts elements from the current realm', () => {
4
+ expect(isHTMLElement(document.createElement('div'))).toBe(true);
5
+ });
6
+ test('accepts element-like objects from another realm (e.g. an iframe)', () => {
7
+ const foreign = { nodeType: 1, style: {} };
8
+ expect(isHTMLElement(foreign)).toBe(true);
9
+ });
10
+ test('rejects non-elements', () => {
11
+ expect(isHTMLElement(null)).toBe(false);
12
+ expect(isHTMLElement(undefined)).toBe(false);
13
+ expect(isHTMLElement('#container')).toBe(false);
14
+ expect(isHTMLElement({})).toBe(false);
15
+ expect(isHTMLElement(document.createTextNode('text'))).toBe(false);
16
+ });
17
+ });
2
18
  describe('createElement', () => {
3
19
  test('creates DOM structure', () => {
4
20
  var _a;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { createDragStream } from '../reactive/drag-stream.js';
2
+ import { effect } from '../reactive/store.js';
3
+ describe('createDragStream', () => {
4
+ beforeAll(() => {
5
+ Object.defineProperty(window, 'matchMedia', {
6
+ writable: true,
7
+ value: jest.fn().mockReturnValue({
8
+ matches: false,
9
+ addListener: jest.fn(),
10
+ removeListener: jest.fn(),
11
+ }),
12
+ });
13
+ if (typeof window.PointerEvent === 'undefined') {
14
+ class FakePointerEvent extends MouseEvent {
15
+ constructor(type, props) {
16
+ super(type, props);
17
+ }
18
+ }
19
+ // @ts-expect-error
20
+ window.PointerEvent = FakePointerEvent;
21
+ // @ts-expect-error
22
+ global.PointerEvent = FakePointerEvent;
23
+ }
24
+ });
25
+ const pointerEvent = (type, props) => {
26
+ var _a;
27
+ const e = new PointerEvent(type, props);
28
+ Object.defineProperty(e, 'pointerId', { value: (_a = props.pointerId) !== null && _a !== void 0 ? _a : 0, configurable: true });
29
+ return e;
30
+ };
31
+ const setup = () => {
32
+ const el = document.createElement('div');
33
+ document.body.appendChild(el);
34
+ jest.spyOn(el, 'getBoundingClientRect').mockReturnValue({
35
+ left: 0,
36
+ top: 0,
37
+ width: 100,
38
+ height: 100,
39
+ right: 100,
40
+ bottom: 100,
41
+ x: 0,
42
+ y: 0,
43
+ toJSON: () => { },
44
+ });
45
+ const stream = createDragStream(el, { threshold: 0 });
46
+ const events = [];
47
+ const unsubscribe = effect(() => {
48
+ const drag = stream.signal.value;
49
+ if (drag)
50
+ events.push(drag);
51
+ }, [stream.signal]);
52
+ return {
53
+ el,
54
+ events,
55
+ cleanup: () => {
56
+ unsubscribe();
57
+ stream.cleanup();
58
+ el.remove();
59
+ },
60
+ };
61
+ };
62
+ test('emits start, move and end on a simple drag', () => {
63
+ const { el, events, cleanup } = setup();
64
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1 }));
65
+ document.dispatchEvent(pointerEvent('pointermove', { clientX: 20, clientY: 20, pointerId: 1 }));
66
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 20, clientY: 20, pointerId: 1 }));
67
+ expect(events.map((e) => e.type)).toEqual(['start', 'move', 'end']);
68
+ cleanup();
69
+ });
70
+ test('drag still works after a two-finger touch', () => {
71
+ const { el, events, cleanup } = setup();
72
+ // Two-finger tap: the second finger lifts before the first
73
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1 }));
74
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 50, clientY: 10, pointerId: 2 }));
75
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 50, clientY: 10, pointerId: 2 }));
76
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 10, clientY: 10, pointerId: 1 }));
77
+ // A subsequent single-finger drag should work
78
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 3 }));
79
+ document.dispatchEvent(pointerEvent('pointermove', { clientX: 30, clientY: 10, pointerId: 3 }));
80
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 30, clientY: 10, pointerId: 3 }));
81
+ expect(events.map((e) => e.type)).toEqual(['start', 'move', 'end']);
82
+ cleanup();
83
+ });
84
+ test('a second finger lifting does not end the first pointer drag', () => {
85
+ const { el, events, cleanup } = setup();
86
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1 }));
87
+ document.dispatchEvent(pointerEvent('pointermove', { clientX: 20, clientY: 10, pointerId: 1 }));
88
+ // A second finger touches and lifts mid-drag
89
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 50, clientY: 10, pointerId: 2 }));
90
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 50, clientY: 10, pointerId: 2 }));
91
+ // The first finger continues dragging and lifts
92
+ document.dispatchEvent(pointerEvent('pointermove', { clientX: 30, clientY: 10, pointerId: 1 }));
93
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 30, clientY: 10, pointerId: 1 }));
94
+ expect(events.map((e) => e.type)).toEqual(['start', 'move', 'move', 'end']);
95
+ cleanup();
96
+ });
97
+ test('only the dragging pointer moves the drag', () => {
98
+ const { el, events, cleanup } = setup();
99
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1 }));
100
+ document.dispatchEvent(pointerEvent('pointermove', { clientX: 20, clientY: 10, pointerId: 1 }));
101
+ // Moves from an untracked pointer are ignored
102
+ document.dispatchEvent(pointerEvent('pointermove', { clientX: 90, clientY: 90, pointerId: 7 }));
103
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 30, clientY: 10, pointerId: 1 }));
104
+ expect(events.map((e) => e.type)).toEqual(['start', 'move', 'end']);
105
+ expect(events[1].x).toBe(20);
106
+ cleanup();
107
+ });
108
+ });
@@ -21,6 +21,40 @@ describe('makeDraggable', () => {
21
21
  global.PointerEvent = FakePointerEvent;
22
22
  }
23
23
  });
24
+ test('drag still works after a two-finger touch', () => {
25
+ const el = document.createElement('div');
26
+ document.body.appendChild(el);
27
+ jest.spyOn(el, 'getBoundingClientRect').mockReturnValue({
28
+ left: 0,
29
+ top: 0,
30
+ width: 100,
31
+ height: 100,
32
+ right: 100,
33
+ bottom: 100,
34
+ x: 0,
35
+ y: 0,
36
+ toJSON: () => { },
37
+ });
38
+ const pointerEvent = (type, props) => {
39
+ const e = new PointerEvent(type, props);
40
+ Object.defineProperty(e, 'pointerId', { value: props.pointerId, configurable: true });
41
+ return e;
42
+ };
43
+ const onDrag = jest.fn();
44
+ const unsubscribe = makeDraggable(el, onDrag, undefined, undefined, 0);
45
+ // Two-finger tap: the second finger lifts before the first
46
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 1 }));
47
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 50, clientY: 10, pointerId: 2 }));
48
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 50, clientY: 10, pointerId: 2 }));
49
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 10, clientY: 10, pointerId: 1 }));
50
+ // A subsequent single-finger drag should work
51
+ el.dispatchEvent(pointerEvent('pointerdown', { clientX: 10, clientY: 10, pointerId: 3 }));
52
+ document.dispatchEvent(pointerEvent('pointermove', { clientX: 30, clientY: 10, pointerId: 3 }));
53
+ document.dispatchEvent(pointerEvent('pointerup', { clientX: 30, clientY: 10, pointerId: 3 }));
54
+ expect(onDrag).toHaveBeenCalled();
55
+ unsubscribe();
56
+ el.remove();
57
+ });
24
58
  test('invokes callbacks on drag', () => {
25
59
  const el = document.createElement('div');
26
60
  document.body.appendChild(el);
@@ -12,6 +12,18 @@ const createEmitter = () => {
12
12
  }),
13
13
  };
14
14
  };
15
+ const createWaveSurfer = (container, durationValue) => {
16
+ const duration = signal(durationValue);
17
+ Object.defineProperty(container, 'getBoundingClientRect', {
18
+ configurable: true,
19
+ value: () => ({ left: 0, width: 100 }),
20
+ });
21
+ document.body.appendChild(container);
22
+ return {
23
+ duration,
24
+ wavesurfer: Object.assign(Object.assign({}, createEmitter()), { options: { progressColor: '#555' }, getDuration: jest.fn(() => duration.value), getState: jest.fn(() => ({ duration })), getWrapper: jest.fn(() => container) }),
25
+ };
26
+ };
15
27
  describe('HoverPlugin', () => {
16
28
  afterEach(() => {
17
29
  document.body.innerHTML = '';
@@ -19,18 +31,34 @@ describe('HoverPlugin', () => {
19
31
  });
20
32
  test('passes the current wavesurfer duration to formatTimeCallback', () => {
21
33
  var _a;
22
- const duration = signal(0);
23
34
  const container = document.createElement('div');
24
35
  const formatTimeCallback = jest.fn((seconds) => `${seconds}`);
25
- Object.defineProperty(container, 'getBoundingClientRect', {
26
- configurable: true,
27
- value: () => ({ left: 0, width: 100 }),
28
- });
29
- document.body.appendChild(container);
36
+ const { wavesurfer } = createWaveSurfer(container, 0);
30
37
  const plugin = HoverPlugin.create({ formatTimeCallback });
31
- plugin._init(Object.assign(Object.assign({}, createEmitter()), { options: { progressColor: '#555' }, getDuration: jest.fn(() => 12), getState: jest.fn(() => ({ duration })), getWrapper: jest.fn(() => container) }));
38
+ plugin._init(wavesurfer);
39
+ wavesurfer.getDuration.mockReturnValue(12);
32
40
  container.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50 }));
33
41
  expect(formatTimeCallback).toHaveBeenCalledWith(6);
34
42
  expect((_a = container.querySelector('[part="hover-label"]')) === null || _a === void 0 ? void 0 : _a.textContent).toBe('6');
35
43
  });
44
+ test('keeps the hover line hidden after pointerleave when duration updates', () => {
45
+ const container = document.createElement('div');
46
+ const formatTimeCallback = jest.fn((seconds) => `${seconds}`);
47
+ const { duration, wavesurfer } = createWaveSurfer(container, 10);
48
+ const plugin = HoverPlugin.create({ formatTimeCallback });
49
+ plugin._init(wavesurfer);
50
+ container.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50 }));
51
+ const hover = container.querySelector('[part="hover"]');
52
+ expect(hover === null || hover === void 0 ? void 0 : hover.style.opacity).toBe('1');
53
+ expect(formatTimeCallback).toHaveBeenCalledTimes(1);
54
+ container.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true }));
55
+ expect(hover === null || hover === void 0 ? void 0 : hover.style.opacity).toBe('0');
56
+ // transform is cleared after the opacity transition ends, not immediately
57
+ hover === null || hover === void 0 ? void 0 : hover.dispatchEvent(new Event('transitionend'));
58
+ expect(hover === null || hover === void 0 ? void 0 : hover.style.transform).toBe('');
59
+ duration.set(12);
60
+ expect(hover === null || hover === void 0 ? void 0 : hover.style.opacity).toBe('0');
61
+ expect(hover === null || hover === void 0 ? void 0 : hover.style.transform).toBe('');
62
+ expect(formatTimeCallback).toHaveBeenCalledTimes(1);
63
+ });
36
64
  });
@@ -17,6 +17,12 @@ const createWaveSurfer = (duration = 10, width = 100, scroll = 0) => {
17
17
  document.body.appendChild(wrapper);
18
18
  return Object.assign(Object.assign({}, emitter), { getDecodedData: jest.fn(() => ({ numberOfChannels: 1 })), getDuration: jest.fn(() => duration), getScroll: jest.fn(() => scroll), getWidth: jest.fn(() => width), getWrapper: jest.fn(() => wrapper) });
19
19
  };
20
+ const mockRect = (element, rect) => {
21
+ Object.defineProperty(element, 'getBoundingClientRect', {
22
+ configurable: true,
23
+ value: jest.fn(() => (Object.assign(Object.assign({}, rect), { right: rect.left + rect.width, bottom: rect.top + rect.height, x: rect.left, y: rect.top, toJSON: () => rect }))),
24
+ });
25
+ };
20
26
  describe('RegionsPlugin', () => {
21
27
  beforeEach(() => {
22
28
  jest.useFakeTimers();
@@ -58,4 +64,37 @@ describe('RegionsPlugin', () => {
58
64
  region.setOptions({ start: 8, end: 9 });
59
65
  expect((_e = region.element) === null || _e === void 0 ? void 0 : _e.parentElement).toBeNull();
60
66
  });
67
+ test('places a region in the first free row instead of summing all prior overlaps', () => {
68
+ var _a, _b, _c;
69
+ const wavesurfer = createWaveSurfer();
70
+ const plugin = RegionsPlugin.create();
71
+ plugin._init(wavesurfer);
72
+ const firstRegion = plugin.addRegion({ start: 0, end: 1, content: 'First' });
73
+ const secondRegion = plugin.addRegion({ start: 1, end: 2, content: 'Second' });
74
+ const thirdRegion = plugin.addRegion({ start: 2, end: 3, content: 'Third' });
75
+ mockRect(firstRegion.content, { left: 0, top: 0, width: 40, height: 10 });
76
+ mockRect(secondRegion.content, { left: 50, top: 0, width: 40, height: 10 });
77
+ mockRect(thirdRegion.content, { left: 30, top: 0, width: 40, height: 10 });
78
+ jest.runOnlyPendingTimers();
79
+ expect((_a = firstRegion.content) === null || _a === void 0 ? void 0 : _a.style.marginTop).toBe('0px');
80
+ expect((_b = secondRegion.content) === null || _b === void 0 ? void 0 : _b.style.marginTop).toBe('0px');
81
+ expect((_c = thirdRegion.content) === null || _c === void 0 ? void 0 : _c.style.marginTop).toBe('12px');
82
+ });
83
+ test('reflows shifted labels when another region moves away', () => {
84
+ var _a, _b, _c;
85
+ const wavesurfer = createWaveSurfer();
86
+ const plugin = RegionsPlugin.create();
87
+ plugin._init(wavesurfer);
88
+ const firstRegion = plugin.addRegion({ start: 0, end: 1, content: 'First' });
89
+ const secondRegion = plugin.addRegion({ start: 1, end: 2, content: 'Second' });
90
+ mockRect(firstRegion.content, { left: 0, top: 0, width: 40, height: 10 });
91
+ mockRect(secondRegion.content, { left: 20, top: 0, width: 40, height: 10 });
92
+ jest.runOnlyPendingTimers();
93
+ expect((_a = secondRegion.content) === null || _a === void 0 ? void 0 : _a.style.marginTop).toBe('12px');
94
+ mockRect(firstRegion.content, { left: 80, top: 0, width: 40, height: 10 });
95
+ firstRegion.onContentBlur();
96
+ jest.runOnlyPendingTimers();
97
+ expect((_b = firstRegion.content) === null || _b === void 0 ? void 0 : _b.style.marginTop).toBe('0px');
98
+ expect((_c = secondRegion.content) === null || _c === void 0 ? void 0 : _c.style.marginTop).toBe('0px');
99
+ });
61
100
  });
@@ -60,6 +60,10 @@ describe('Renderer', () => {
60
60
  container.remove();
61
61
  jest.clearAllMocks();
62
62
  });
63
+ test('parentFromOptionsContainer accepts an element from another realm (e.g. an iframe)', () => {
64
+ const foreign = { nodeType: 1, style: {} };
65
+ expect(renderer.parentFromOptionsContainer(foreign)).toBe(foreign);
66
+ });
63
67
  test('parentFromOptionsContainer returns element and throws', () => {
64
68
  expect(renderer.parentFromOptionsContainer(container)).toBe(container);
65
69
  expect(renderer.parentFromOptionsContainer('#root')).toBe(container);
@@ -178,6 +178,19 @@ describe('WaveSurfer public methods', () => {
178
178
  expect(spy).toHaveBeenCalledWith(2);
179
179
  expect(ws.stopAtPosition).toBe(4);
180
180
  }));
181
+ test('pauses and clamps time to the exact stop position when playback overshoots it', () => __awaiter(void 0, void 0, void 0, function* () {
182
+ const ws = createWs();
183
+ const media = ws.getMediaElement();
184
+ Object.defineProperty(media, 'paused', { configurable: true, value: false });
185
+ yield ws.play(1, 2);
186
+ // Simulate the clock overshooting the stop position between timer ticks
187
+ media.currentTime = 2.013;
188
+ const timer = getTimer();
189
+ const tick = timer.on.mock.calls.find(([event]) => event === 'tick')[1];
190
+ tick();
191
+ expect(media.pause).toHaveBeenCalled();
192
+ expect(ws.getCurrentTime()).toBe(2);
193
+ }));
181
194
  test('playPause toggles play and pause', () => __awaiter(void 0, void 0, void 0, function* () {
182
195
  const ws = createWs();
183
196
  const media = ws.getMediaElement();
@@ -179,6 +179,31 @@ describe('WebAudioPlayer', () => {
179
179
  triggerOnended();
180
180
  expect(endedSpy).toHaveBeenCalledTimes(1);
181
181
  });
182
+ test('stopAt schedules the stop scaled by playback rate', () => {
183
+ const { audioContext, bufferSource } = createMockAudioContext();
184
+ const player = new WebAudioPlayer(audioContext);
185
+ player.buffer = createMockBuffer(10);
186
+ player._playbackRate = 2;
187
+ audioContext.currentTime = 100;
188
+ player.play();
189
+ // At 2x rate, stopping at media time 5 takes only 2.5s of real time
190
+ player.stopAt(5);
191
+ expect(bufferSource.stop).toHaveBeenCalledWith(102.5);
192
+ });
193
+ test('stopAt reports exactly the stop position after the buffer ends', () => {
194
+ var _a;
195
+ const { audioContext, bufferSource } = createMockAudioContext();
196
+ const player = new WebAudioPlayer(audioContext);
197
+ player.buffer = createMockBuffer(10);
198
+ audioContext.currentTime = 100;
199
+ player.play();
200
+ player.stopAt(5);
201
+ // The 'ended' event fires with some latency after the actual stop
202
+ audioContext.currentTime = 105.03;
203
+ const endedListener = (_a = bufferSource.addEventListener.mock.calls.find(([type]) => type === 'ended')) === null || _a === void 0 ? void 0 : _a[1];
204
+ endedListener === null || endedListener === void 0 ? void 0 : endedListener();
205
+ expect(player.currentTime).toBe(5);
206
+ });
182
207
  test('does not emit ended when currentTime is beyond tolerance threshold from duration', () => {
183
208
  const { audioContext, triggerOnended } = createMockAudioContext();
184
209
  const player = new WebAudioPlayer(audioContext);
package/dist/dom.d.ts CHANGED
@@ -10,4 +10,9 @@ export declare function createElement(tagName: string, content: TreeNode & {
10
10
  xmlns: string;
11
11
  }, container?: Node): SVGElement;
12
12
  export declare function createElement(tagName: string, content?: TreeNode, container?: Node): HTMLElement;
13
+ /**
14
+ * Check if a value is an HTML element, including elements from other realms
15
+ * (e.g. an iframe), for which `instanceof HTMLElement` returns false
16
+ */
17
+ export declare function isHTMLElement(value: unknown): value is HTMLElement;
13
18
  export default createElement;
package/dist/dom.js CHANGED
@@ -33,4 +33,16 @@ export function createElement(tagName, content, container) {
33
33
  container === null || container === void 0 ? void 0 : container.appendChild(el);
34
34
  return el;
35
35
  }
36
+ /**
37
+ * Check if a value is an HTML element, including elements from other realms
38
+ * (e.g. an iframe), for which `instanceof HTMLElement` returns false
39
+ */
40
+ export function isHTMLElement(value) {
41
+ if (value instanceof HTMLElement)
42
+ return true;
43
+ return (typeof value === 'object' &&
44
+ value !== null &&
45
+ value.nodeType === Node.ELEMENT_NODE &&
46
+ typeof value.style === 'object');
47
+ }
36
48
  export default createElement;
package/dist/draggable.js CHANGED
@@ -11,15 +11,20 @@ export function makeDraggable(element, onDrag, onStart, onEnd, threshold = 3, mo
11
11
  const onPointerDown = (event) => {
12
12
  if (event.button !== mouseButton)
13
13
  return;
14
+ if (activePointers.has(event.pointerId))
15
+ return;
14
16
  activePointers.set(event.pointerId, event);
15
17
  if (activePointers.size > 1) {
16
18
  return;
17
19
  }
20
+ const dragPointerId = event.pointerId;
18
21
  let startX = event.clientX;
19
22
  let startY = event.clientY;
20
23
  let isDragging = false;
21
24
  const touchStartTime = Date.now();
22
25
  const onPointerMove = (event) => {
26
+ if (event.pointerId !== dragPointerId)
27
+ return;
23
28
  if (event.defaultPrevented || activePointers.size > 1) {
24
29
  return;
25
30
  }
@@ -44,18 +49,23 @@ export function makeDraggable(element, onDrag, onStart, onEnd, threshold = 3, mo
44
49
  }
45
50
  };
46
51
  const onPointerUp = (event) => {
47
- activePointers.delete(event.pointerId);
48
- if (isDragging) {
52
+ // Only react to pointers that started on the element
53
+ if (!activePointers.delete(event.pointerId))
54
+ return;
55
+ // Only the pointer that started the drag can end it
56
+ if (event.pointerId === dragPointerId && isDragging) {
49
57
  const x = event.clientX;
50
58
  const y = event.clientY;
51
59
  const rect = element.getBoundingClientRect();
52
60
  const { left, top } = rect;
53
61
  onEnd === null || onEnd === void 0 ? void 0 : onEnd(x - left, y - top);
54
62
  }
55
- unsubscribeDocument();
63
+ // Keep listening until all pointers are released
64
+ if (activePointers.size === 0) {
65
+ unsubscribeDocument();
66
+ }
56
67
  };
57
68
  const onPointerLeave = (e) => {
58
- activePointers.delete(e.pointerId);
59
69
  if (!e.relatedTarget || e.relatedTarget === document.documentElement) {
60
70
  onPointerUp(e);
61
71
  }
package/dist/fetcher.js CHANGED
@@ -7,20 +7,25 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- function watchProgress(response, progressCallback) {
10
+ function watchProgress(response, progressCallback, signal) {
11
11
  return __awaiter(this, void 0, void 0, function* () {
12
+ var _a;
12
13
  if (!response.body || !response.headers)
13
14
  return;
14
15
  const reader = response.body.getReader();
15
16
  const contentLength = Number(response.headers.get('Content-Length')) || 0;
16
17
  let receivedLength = 0;
17
- // Process the data
18
- const processChunk = (value) => {
19
- // Add to the received length
20
- receivedLength += (value === null || value === void 0 ? void 0 : value.length) || 0;
21
- const percentage = Math.round((receivedLength / contentLength) * 100);
22
- progressCallback(percentage);
18
+ // Abort the reader when the signal fires
19
+ const onAbort = () => {
20
+ reader.cancel();
23
21
  };
22
+ if (signal) {
23
+ if (signal.aborted) {
24
+ reader.cancel();
25
+ return;
26
+ }
27
+ signal.addEventListener('abort', onAbort, { once: true });
28
+ }
24
29
  // Use iteration instead of recursion to avoid stack issues
25
30
  try {
26
31
  while (true) {
@@ -28,24 +33,40 @@ function watchProgress(response, progressCallback) {
28
33
  if (data.done) {
29
34
  break;
30
35
  }
31
- processChunk(data.value);
36
+ receivedLength += ((_a = data.value) === null || _a === void 0 ? void 0 : _a.length) || 0;
37
+ // Only report progress if Content-Length is available and non-zero
38
+ if (contentLength > 0) {
39
+ const percentage = Math.round((receivedLength / contentLength) * 100);
40
+ progressCallback(percentage);
41
+ }
32
42
  }
33
43
  }
34
44
  catch (err) {
35
- // Ignore errors because we can only handle the main response
45
+ // Ignore abort errors from reader cancellation
46
+ if (err instanceof DOMException && err.name === 'AbortError')
47
+ return;
48
+ // Ignore other errors because we can only handle the main response
36
49
  console.warn('Progress tracking error:', err);
37
50
  }
51
+ finally {
52
+ // Remove the abort listener to prevent leaks
53
+ if (signal) {
54
+ signal.removeEventListener('abort', onAbort);
55
+ }
56
+ }
38
57
  });
39
58
  }
40
59
  function fetchBlob(url, progressCallback, requestInit) {
41
60
  return __awaiter(this, void 0, void 0, function* () {
61
+ var _a;
42
62
  // Fetch the resource
43
63
  const response = yield fetch(url, requestInit);
44
64
  if (response.status >= 400) {
45
65
  throw new Error(`Failed to fetch ${url}: ${response.status} (${response.statusText})`);
46
66
  }
47
67
  // Read the data to track progress
48
- watchProgress(response.clone(), progressCallback);
68
+ // Pass the abort signal so the progress reader can be cancelled
69
+ watchProgress(response.clone(), progressCallback, (_a = requestInit === null || requestInit === void 0 ? void 0 : requestInit.signal) !== null && _a !== void 0 ? _a : undefined);
49
70
  return response.blob();
50
71
  });
51
72
  }
package/dist/fft.js CHANGED
@@ -535,7 +535,11 @@ function FFT(bufferSize, sampleRate, windowFunc, alpha) {
535
535
  limit = limit << 1;
536
536
  bit = bit >> 1;
537
537
  }
538
- for (let i = 0; i < bufferSize; i++) {
538
+ // Precompute sin/cos tables for FFT twiddle factors
539
+ // Index 0 is a special case: -PI/0 would produce NaN, use 0 instead
540
+ this.sinTable[0] = 0;
541
+ this.cosTable[0] = 1;
542
+ for (let i = 1; i < bufferSize; i++) {
539
543
  this.sinTable[i] = Math.sin(-Math.PI / i);
540
544
  this.cosTable[i] = Math.cos(-Math.PI / i);
541
545
  }
package/dist/player.d.ts CHANGED
@@ -9,6 +9,7 @@ type PlayerOptions = {
9
9
  declare class Player<T extends GeneralEventTypes> extends EventEmitter<T> {
10
10
  protected media: HTMLMediaElement;
11
11
  private isExternalMedia;
12
+ private _ownBlobUrl;
12
13
  private _isPlaying;
13
14
  private _currentTime;
14
15
  private _duration;
package/dist/player.js CHANGED
@@ -36,6 +36,7 @@ class Player extends EventEmitter {
36
36
  constructor(options) {
37
37
  super();
38
38
  this.isExternalMedia = false;
39
+ this._ownBlobUrl = null;
39
40
  this.reactiveMediaEventCleanups = [];
40
41
  if (options.media) {
41
42
  this.media = options.media;
@@ -121,9 +122,10 @@ class Player extends EventEmitter {
121
122
  return this.media.currentSrc || this.media.src || '';
122
123
  }
123
124
  revokeSrc() {
124
- const src = this.getSrc();
125
- if (src.startsWith('blob:')) {
126
- URL.revokeObjectURL(src);
125
+ // Only revoke blob URLs that we created (not ones owned by external callers)
126
+ if (this._ownBlobUrl) {
127
+ URL.revokeObjectURL(this._ownBlobUrl);
128
+ this._ownBlobUrl = null;
127
129
  }
128
130
  }
129
131
  canPlayType(type) {
@@ -135,6 +137,10 @@ class Player extends EventEmitter {
135
137
  return; // no need to change the source
136
138
  this.revokeSrc();
137
139
  const newSrc = blob instanceof Blob && (this.canPlayType(blob.type) || !url) ? URL.createObjectURL(blob) : url;
140
+ // Track blob URLs we created so we can revoke them on destroy
141
+ if (newSrc !== url) {
142
+ this._ownBlobUrl = newSrc;
143
+ }
138
144
  // Reset the media element, otherwise it keeps the previous source
139
145
  if (prevSrc) {
140
146
  this.media.removeAttribute('src');
@@ -152,10 +158,13 @@ class Player extends EventEmitter {
152
158
  // Cleanup reactive media event listeners
153
159
  this.reactiveMediaEventCleanups.forEach((cleanup) => cleanup());
154
160
  this.reactiveMediaEventCleanups = [];
161
+ // Revoke blob URLs that we created
162
+ this.revokeSrc();
163
+ // Clear all event emitter listeners
164
+ this.unAll();
155
165
  if (this.isExternalMedia)
156
166
  return;
157
167
  this.media.pause();
158
- this.revokeSrc();
159
168
  this.media.removeAttribute('src');
160
169
  // Load resets the media element to its initial state
161
170
  this.media.load();