wavesurfer.js 7.12.6 → 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 (73) 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.d.ts +1 -0
  7. package/dist/__tests__/hover.test.js +64 -0
  8. package/dist/__tests__/regions.test.js +39 -0
  9. package/dist/__tests__/renderer.test.js +4 -0
  10. package/dist/__tests__/wavesurfer.test.js +13 -0
  11. package/dist/__tests__/webaudio.test.d.ts +1 -0
  12. package/dist/__tests__/webaudio.test.js +223 -0
  13. package/dist/dom.d.ts +5 -0
  14. package/dist/dom.js +12 -0
  15. package/dist/draggable.js +14 -4
  16. package/dist/fetcher.js +31 -10
  17. package/dist/fft.js +5 -1
  18. package/dist/player.d.ts +1 -0
  19. package/dist/player.js +13 -4
  20. package/dist/plugins/envelope.cjs +1 -1
  21. package/dist/plugins/envelope.esm.js +1 -1
  22. package/dist/plugins/envelope.js +1 -1
  23. package/dist/plugins/envelope.min.js +1 -1
  24. package/dist/plugins/hover.cjs +1 -1
  25. package/dist/plugins/hover.d.ts +2 -0
  26. package/dist/plugins/hover.esm.js +1 -1
  27. package/dist/plugins/hover.js +1 -1
  28. package/dist/plugins/hover.min.js +1 -1
  29. package/dist/plugins/minimap.cjs +1 -1
  30. package/dist/plugins/minimap.esm.js +1 -1
  31. package/dist/plugins/minimap.js +1 -1
  32. package/dist/plugins/minimap.min.js +1 -1
  33. package/dist/plugins/record.cjs +1 -1
  34. package/dist/plugins/record.esm.js +1 -1
  35. package/dist/plugins/record.js +1 -1
  36. package/dist/plugins/record.min.js +1 -1
  37. package/dist/plugins/regions.cjs +1 -1
  38. package/dist/plugins/regions.d.ts +1 -0
  39. package/dist/plugins/regions.esm.js +1 -1
  40. package/dist/plugins/regions.js +1 -1
  41. package/dist/plugins/regions.min.js +1 -1
  42. package/dist/plugins/spectrogram-windowed.cjs +1 -1
  43. package/dist/plugins/spectrogram-windowed.esm.js +1 -1
  44. package/dist/plugins/spectrogram-windowed.js +1 -1
  45. package/dist/plugins/spectrogram-windowed.min.js +1 -1
  46. package/dist/plugins/spectrogram.cjs +1 -1
  47. package/dist/plugins/spectrogram.esm.js +1 -1
  48. package/dist/plugins/spectrogram.js +1 -1
  49. package/dist/plugins/spectrogram.min.js +1 -1
  50. package/dist/plugins/timeline.cjs +1 -1
  51. package/dist/plugins/timeline.esm.js +1 -1
  52. package/dist/plugins/timeline.js +1 -1
  53. package/dist/plugins/timeline.min.js +1 -1
  54. package/dist/plugins/zoom.cjs +1 -1
  55. package/dist/plugins/zoom.d.ts +1 -0
  56. package/dist/plugins/zoom.esm.js +1 -1
  57. package/dist/plugins/zoom.js +1 -1
  58. package/dist/plugins/zoom.min.js +1 -1
  59. package/dist/reactive/drag-stream.js +14 -4
  60. package/dist/reactive/state-event-emitter.js +7 -0
  61. package/dist/renderer-utils.js +4 -2
  62. package/dist/renderer.d.ts +2 -0
  63. package/dist/renderer.js +23 -11
  64. package/dist/timer.js +1 -0
  65. package/dist/types.d.ts +18 -2
  66. package/dist/wavesurfer.cjs +1 -1
  67. package/dist/wavesurfer.d.ts +2 -0
  68. package/dist/wavesurfer.esm.js +1 -1
  69. package/dist/wavesurfer.js +35 -6
  70. package/dist/wavesurfer.min.js +1 -1
  71. package/dist/webaudio.d.ts +5 -0
  72. package/dist/webaudio.js +69 -6
  73. 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);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ import HoverPlugin from '../plugins/hover.js';
2
+ import { signal } from '../reactive/store.js';
3
+ const createEmitter = () => {
4
+ const listeners = new Map();
5
+ return {
6
+ on: jest.fn((event, listener) => {
7
+ if (!listeners.has(event)) {
8
+ listeners.set(event, new Set());
9
+ }
10
+ listeners.get(event).add(listener);
11
+ return () => { var _a; return (_a = listeners.get(event)) === null || _a === void 0 ? void 0 : _a.delete(listener); };
12
+ }),
13
+ };
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
+ };
27
+ describe('HoverPlugin', () => {
28
+ afterEach(() => {
29
+ document.body.innerHTML = '';
30
+ jest.clearAllMocks();
31
+ });
32
+ test('passes the current wavesurfer duration to formatTimeCallback', () => {
33
+ var _a;
34
+ const container = document.createElement('div');
35
+ const formatTimeCallback = jest.fn((seconds) => `${seconds}`);
36
+ const { wavesurfer } = createWaveSurfer(container, 0);
37
+ const plugin = HoverPlugin.create({ formatTimeCallback });
38
+ plugin._init(wavesurfer);
39
+ wavesurfer.getDuration.mockReturnValue(12);
40
+ container.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50 }));
41
+ expect(formatTimeCallback).toHaveBeenCalledWith(6);
42
+ expect((_a = container.querySelector('[part="hover-label"]')) === null || _a === void 0 ? void 0 : _a.textContent).toBe('6');
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
+ });
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();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,223 @@
1
+ import WebAudioPlayer from '../webaudio.js';
2
+ // Mock Web Audio API
3
+ function createMockAudioContext() {
4
+ let _currentTime = 0;
5
+ const gainNode = {
6
+ gain: { value: 1 },
7
+ connect: jest.fn(),
8
+ disconnect: jest.fn(),
9
+ };
10
+ let bufferSourceOnended = null;
11
+ const bufferSource = {
12
+ buffer: null,
13
+ playbackRate: { value: 1 },
14
+ connect: jest.fn(),
15
+ disconnect: jest.fn(),
16
+ start: jest.fn(),
17
+ stop: jest.fn(),
18
+ addEventListener: jest.fn(),
19
+ get onended() {
20
+ return bufferSourceOnended;
21
+ },
22
+ set onended(fn) {
23
+ bufferSourceOnended = fn;
24
+ },
25
+ };
26
+ const audioContext = {
27
+ get currentTime() {
28
+ return _currentTime;
29
+ },
30
+ set currentTime(v) {
31
+ _currentTime = v;
32
+ },
33
+ sampleRate: 44100,
34
+ destination: {},
35
+ createGain: jest.fn(() => gainNode),
36
+ createBufferSource: jest.fn(() => bufferSource),
37
+ decodeAudioData: jest.fn(),
38
+ };
39
+ return {
40
+ audioContext,
41
+ gainNode,
42
+ bufferSource,
43
+ triggerOnended: () => {
44
+ if (bufferSourceOnended)
45
+ bufferSourceOnended();
46
+ },
47
+ };
48
+ }
49
+ function createMockBuffer(duration) {
50
+ return { duration, numberOfChannels: 1, getChannelData: jest.fn(() => new Float32Array(0)) };
51
+ }
52
+ describe('WebAudioPlayer', () => {
53
+ describe('audio session playback mode', () => {
54
+ const originalAudioSession = Object.getOwnPropertyDescriptor(navigator, 'audioSession');
55
+ afterEach(() => {
56
+ if (originalAudioSession) {
57
+ Object.defineProperty(navigator, 'audioSession', originalAudioSession);
58
+ }
59
+ else {
60
+ delete navigator.audioSession;
61
+ }
62
+ jest.restoreAllMocks();
63
+ });
64
+ test('sets navigator.audioSession.type to playback when available', () => {
65
+ const { audioContext } = createMockAudioContext();
66
+ const audioSession = { type: 'ambient' };
67
+ Object.defineProperty(navigator, 'audioSession', { configurable: true, value: audioSession });
68
+ new WebAudioPlayer(audioContext);
69
+ expect(audioSession.type).toBe('playback');
70
+ });
71
+ test('warns when setting navigator.audioSession.type fails', () => {
72
+ const { audioContext } = createMockAudioContext();
73
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
74
+ const audioSession = {
75
+ get type() {
76
+ return 'ambient';
77
+ },
78
+ set type(_value) {
79
+ throw new Error('nope');
80
+ },
81
+ };
82
+ Object.defineProperty(navigator, 'audioSession', { configurable: true, value: audioSession });
83
+ new WebAudioPlayer(audioContext);
84
+ expect(warnSpy).toHaveBeenCalledWith('Setting navigator.audioSession.type failed:', expect.objectContaining({ message: 'nope' }));
85
+ });
86
+ });
87
+ describe('onended and finish event', () => {
88
+ test('emits ended when buffer finishes naturally at duration', () => {
89
+ const { audioContext, triggerOnended } = createMockAudioContext();
90
+ const player = new WebAudioPlayer(audioContext);
91
+ const endedSpy = jest.fn();
92
+ player.on('ended', endedSpy);
93
+ player.buffer = createMockBuffer(10);
94
+ // Play
95
+ audioContext.currentTime = 100;
96
+ player.play();
97
+ // Simulate natural end: audioContext.currentTime has advanced by exactly the duration
98
+ audioContext.currentTime = 110; // 100 + 10 seconds of audio
99
+ triggerOnended();
100
+ expect(endedSpy).toHaveBeenCalledTimes(1);
101
+ });
102
+ test('emits ended when buffer finishes naturally and currentTime is slightly less than duration due to float precision', () => {
103
+ const { audioContext, triggerOnended } = createMockAudioContext();
104
+ const player = new WebAudioPlayer(audioContext);
105
+ const endedSpy = jest.fn();
106
+ player.on('ended', endedSpy);
107
+ player.buffer = createMockBuffer(10);
108
+ // Play
109
+ audioContext.currentTime = 100;
110
+ player.play();
111
+ // Simulate natural end with slight floating-point imprecision
112
+ // currentTime would be 0 + (109.999 - 100) * 1 = 9.999, which is < 10 (duration)
113
+ audioContext.currentTime = 109.999;
114
+ triggerOnended();
115
+ expect(endedSpy).toHaveBeenCalledTimes(1);
116
+ });
117
+ test('does not emit ended when paused programmatically (far from end)', () => {
118
+ const { audioContext, triggerOnended } = createMockAudioContext();
119
+ const player = new WebAudioPlayer(audioContext);
120
+ const endedSpy = jest.fn();
121
+ player.on('ended', endedSpy);
122
+ player.buffer = createMockBuffer(10);
123
+ // Play
124
+ audioContext.currentTime = 100;
125
+ player.play();
126
+ // Advance to mid-playback
127
+ audioContext.currentTime = 105;
128
+ // User pauses (sets this.paused = true synchronously)
129
+ player.pause();
130
+ // onended fires asynchronously after stop()
131
+ triggerOnended();
132
+ expect(endedSpy).not.toHaveBeenCalled();
133
+ });
134
+ test('does not emit ended when stopAt stops before end of audio', () => {
135
+ const { audioContext, triggerOnended } = createMockAudioContext();
136
+ const player = new WebAudioPlayer(audioContext);
137
+ const endedSpy = jest.fn();
138
+ player.on('ended', endedSpy);
139
+ player.buffer = createMockBuffer(10);
140
+ // Play
141
+ audioContext.currentTime = 100;
142
+ player.play();
143
+ // Stop at 5 seconds
144
+ audioContext.currentTime = 100;
145
+ player.stopAt(5);
146
+ // Simulate buffer stopping at 5 seconds
147
+ audioContext.currentTime = 105;
148
+ triggerOnended();
149
+ // Should NOT emit ended because currentTime (5) is far from duration (10)
150
+ expect(endedSpy).not.toHaveBeenCalled();
151
+ });
152
+ test('emits ended when currentTime slightly exceeds duration', () => {
153
+ const { audioContext, triggerOnended } = createMockAudioContext();
154
+ const player = new WebAudioPlayer(audioContext);
155
+ const endedSpy = jest.fn();
156
+ player.on('ended', endedSpy);
157
+ player.buffer = createMockBuffer(10);
158
+ // Play
159
+ audioContext.currentTime = 100;
160
+ player.play();
161
+ // Simulate natural end: audioContext has advanced slightly past the duration
162
+ audioContext.currentTime = 110.005;
163
+ triggerOnended();
164
+ expect(endedSpy).toHaveBeenCalledTimes(1);
165
+ });
166
+ test('emits ended with non-default playback rate', () => {
167
+ const { audioContext, triggerOnended } = createMockAudioContext();
168
+ const player = new WebAudioPlayer(audioContext);
169
+ const endedSpy = jest.fn();
170
+ player.on('ended', endedSpy);
171
+ player.buffer = createMockBuffer(10);
172
+ player._playbackRate = 2;
173
+ // Play
174
+ audioContext.currentTime = 100;
175
+ player.play();
176
+ // At 2x, 10s audio takes 5s real time
177
+ // currentTime = 0 + (104.999 - 100) * 2 = 9.998, which is < 10 but within tolerance
178
+ audioContext.currentTime = 104.999;
179
+ triggerOnended();
180
+ expect(endedSpy).toHaveBeenCalledTimes(1);
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
+ });
207
+ test('does not emit ended when currentTime is beyond tolerance threshold from duration', () => {
208
+ const { audioContext, triggerOnended } = createMockAudioContext();
209
+ const player = new WebAudioPlayer(audioContext);
210
+ const endedSpy = jest.fn();
211
+ player.on('ended', endedSpy);
212
+ player.buffer = createMockBuffer(10);
213
+ // Play
214
+ audioContext.currentTime = 100;
215
+ player.play();
216
+ // Simulate stopAt scenario where currentTime is 0.02s before duration (beyond 0.01s tolerance)
217
+ // currentTime = 0 + (109.98 - 100) * 1 = 9.98, duration - currentTime = 0.02 >= 0.01
218
+ audioContext.currentTime = 109.98;
219
+ triggerOnended();
220
+ expect(endedSpy).not.toHaveBeenCalled();
221
+ });
222
+ });
223
+ });
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;