wavesurfer.js 7.11.1 → 7.12.1

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 (86) hide show
  1. package/README.md +1 -1
  2. package/dist/__tests__/memory-leaks.test.d.ts +7 -0
  3. package/dist/__tests__/memory-leaks.test.js +281 -0
  4. package/dist/__tests__/player.test.js +107 -0
  5. package/dist/__tests__/renderer-utils.test.js +55 -2
  6. package/dist/draggable.d.ts +4 -0
  7. package/dist/draggable.js +4 -0
  8. package/dist/player.d.ts +21 -0
  9. package/dist/player.js +86 -0
  10. package/dist/plugins/envelope.cjs +1 -1
  11. package/dist/plugins/envelope.esm.js +1 -1
  12. package/dist/plugins/envelope.js +1 -1
  13. package/dist/plugins/envelope.min.js +1 -1
  14. package/dist/plugins/hover.cjs +1 -1
  15. package/dist/plugins/hover.d.ts +0 -3
  16. package/dist/plugins/hover.esm.js +1 -1
  17. package/dist/plugins/hover.js +1 -1
  18. package/dist/plugins/hover.min.js +1 -1
  19. package/dist/plugins/minimap.cjs +1 -1
  20. package/dist/plugins/minimap.esm.js +1 -1
  21. package/dist/plugins/minimap.js +1 -1
  22. package/dist/plugins/minimap.min.js +1 -1
  23. package/dist/plugins/record.cjs +1 -1
  24. package/dist/plugins/record.esm.js +1 -1
  25. package/dist/plugins/record.js +1 -1
  26. package/dist/plugins/record.min.js +1 -1
  27. package/dist/plugins/regions.cjs +1 -1
  28. package/dist/plugins/regions.esm.js +1 -1
  29. package/dist/plugins/regions.js +1 -1
  30. package/dist/plugins/regions.min.js +1 -1
  31. package/dist/plugins/timeline.cjs +1 -1
  32. package/dist/plugins/timeline.d.ts +1 -1
  33. package/dist/plugins/timeline.esm.js +1 -1
  34. package/dist/plugins/timeline.js +1 -1
  35. package/dist/plugins/timeline.min.js +1 -1
  36. package/dist/plugins/zoom.cjs +1 -1
  37. package/dist/plugins/zoom.esm.js +1 -1
  38. package/dist/plugins/zoom.js +1 -1
  39. package/dist/plugins/zoom.min.js +1 -1
  40. package/dist/reactive/__tests__/drag-stream.test.d.ts +1 -0
  41. package/dist/reactive/__tests__/drag-stream.test.js +199 -0
  42. package/dist/reactive/__tests__/event-stream-emitter.test.d.ts +1 -0
  43. package/dist/reactive/__tests__/event-stream-emitter.test.js +230 -0
  44. package/dist/reactive/__tests__/event-streams.test.d.ts +1 -0
  45. package/dist/reactive/__tests__/event-streams.test.js +278 -0
  46. package/dist/reactive/__tests__/media-event-bridge.test.d.ts +1 -0
  47. package/dist/reactive/__tests__/media-event-bridge.test.js +202 -0
  48. package/dist/reactive/__tests__/render-scheduler.test.d.ts +1 -0
  49. package/dist/reactive/__tests__/render-scheduler.test.js +216 -0
  50. package/dist/reactive/__tests__/scroll-stream.test.d.ts +1 -0
  51. package/dist/reactive/__tests__/scroll-stream.test.js +191 -0
  52. package/dist/reactive/__tests__/state-event-emitter.test.d.ts +1 -0
  53. package/dist/reactive/__tests__/state-event-emitter.test.js +234 -0
  54. package/dist/reactive/__tests__/store.test.d.ts +1 -0
  55. package/dist/reactive/__tests__/store.test.js +271 -0
  56. package/dist/reactive/drag-stream.d.ts +48 -0
  57. package/dist/reactive/drag-stream.js +146 -0
  58. package/dist/reactive/event-stream-emitter.d.ts +104 -0
  59. package/dist/reactive/event-stream-emitter.js +151 -0
  60. package/dist/reactive/event-streams.d.ts +65 -0
  61. package/dist/reactive/event-streams.js +146 -0
  62. package/dist/reactive/media-event-bridge.d.ts +51 -0
  63. package/dist/reactive/media-event-bridge.js +152 -0
  64. package/dist/reactive/render-scheduler.d.ts +43 -0
  65. package/dist/reactive/render-scheduler.js +75 -0
  66. package/dist/reactive/scroll-stream.d.ts +93 -0
  67. package/dist/reactive/scroll-stream.js +131 -0
  68. package/dist/reactive/state-event-emitter.d.ts +105 -0
  69. package/dist/reactive/state-event-emitter.js +213 -0
  70. package/dist/reactive/store.d.ts +66 -0
  71. package/dist/reactive/store.js +115 -0
  72. package/dist/renderer-utils.d.ts +13 -4
  73. package/dist/renderer-utils.js +35 -15
  74. package/dist/renderer.d.ts +2 -1
  75. package/dist/renderer.js +51 -48
  76. package/dist/state/__tests__/wavesurfer-state.test.d.ts +1 -0
  77. package/dist/state/__tests__/wavesurfer-state.test.js +268 -0
  78. package/dist/state/wavesurfer-state.d.ts +90 -0
  79. package/dist/state/wavesurfer-state.js +124 -0
  80. package/dist/types.d.ts +165 -1
  81. package/dist/wavesurfer.cjs +1 -1
  82. package/dist/wavesurfer.d.ts +16 -0
  83. package/dist/wavesurfer.esm.js +1 -1
  84. package/dist/wavesurfer.js +32 -0
  85. package/dist/wavesurfer.min.js +1 -1
  86. package/package.json +2 -3
package/README.md CHANGED
@@ -112,7 +112,7 @@ However, please keep in mind that this forum is dedicated to wavesurfer-specific
112
112
 
113
113
  <details>
114
114
  <summary>Does wavesurfer support large files?</summary>
115
- Since wavesurfer decodes audio entirely in the browser using Web Audio, large clips may fail to decode due to memory constraints. We recommend using pre-decoded peaks for large files (see <a href="https://wavesurfer.xyz/examples/?predecoded.js">this example</a>). You can use a tool like <a href="https://github.com/bbc/audiowaveform">bbc/audiowaveform</a> to generate peaks.
115
+ Since wavesurfer decodes audio entirely in the browser using Web Audio, large clips may fail to decode due to memory constraints. We recommend using pre-decoded peaks for large files (see <a href="https://wavesurfer.xyz/examples/?predecoded.js">this example</a>). You can use a tool like <a href="https://codeberg.org/chrisn/audiowaveform">audiowaveform</a> to generate peaks.
116
116
  </details>
117
117
 
118
118
  <details>
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Memory Leak Detection Tests
3
+ *
4
+ * These tests verify that WaveSurfer properly cleans up resources
5
+ * and doesn't leak memory when destroyed and recreated multiple times.
6
+ */
7
+ export {};
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Memory Leak Detection Tests
3
+ *
4
+ * These tests verify that WaveSurfer properly cleans up resources
5
+ * and doesn't leak memory when destroyed and recreated multiple times.
6
+ */
7
+ import WaveSurfer from '../wavesurfer.js';
8
+ import RegionsPlugin from '../plugins/regions.js';
9
+ // Mock audio context and matchMedia
10
+ beforeAll(() => {
11
+ global.AudioContext = jest.fn().mockImplementation(() => ({
12
+ createMediaElementSource: jest.fn(() => ({
13
+ connect: jest.fn(),
14
+ disconnect: jest.fn(),
15
+ })),
16
+ createGain: jest.fn(() => ({
17
+ connect: jest.fn(),
18
+ disconnect: jest.fn(),
19
+ gain: { value: 1, setValueAtTime: jest.fn() },
20
+ })),
21
+ destination: {},
22
+ close: jest.fn(),
23
+ }));
24
+ // Mock matchMedia for drag-stream
25
+ Object.defineProperty(window, 'matchMedia', {
26
+ writable: true,
27
+ value: jest.fn().mockImplementation((query) => ({
28
+ matches: false,
29
+ media: query,
30
+ onchange: null,
31
+ addListener: jest.fn(),
32
+ removeListener: jest.fn(),
33
+ addEventListener: jest.fn(),
34
+ removeEventListener: jest.fn(),
35
+ dispatchEvent: jest.fn(),
36
+ })),
37
+ });
38
+ });
39
+ describe('Memory Leak Detection', () => {
40
+ let container;
41
+ beforeEach(() => {
42
+ container = document.createElement('div');
43
+ container.id = 'waveform';
44
+ document.body.appendChild(container);
45
+ });
46
+ afterEach(() => {
47
+ document.body.removeChild(container);
48
+ });
49
+ describe('WaveSurfer lifecycle', () => {
50
+ it('should cleanup subscriptions on destroy', () => {
51
+ const ws = WaveSurfer.create({ container });
52
+ // Track if cleanup functions are called
53
+ const cleanupSpy = jest.fn();
54
+ // Access internal state to verify cleanup
55
+ const originalDestroy = ws.destroy.bind(ws);
56
+ ws.destroy = () => {
57
+ cleanupSpy();
58
+ originalDestroy();
59
+ };
60
+ ws.destroy();
61
+ expect(cleanupSpy).toHaveBeenCalled();
62
+ });
63
+ it('should not leak memory after multiple create/destroy cycles', () => {
64
+ const instances = [];
65
+ // Create and destroy multiple instances
66
+ for (let i = 0; i < 10; i++) {
67
+ const ws = WaveSurfer.create({ container });
68
+ instances.push(ws);
69
+ ws.destroy();
70
+ }
71
+ // All instances should be destroyed
72
+ instances.forEach((ws) => {
73
+ // After destroy, the instance should not have active listeners
74
+ expect(ws).toBeDefined();
75
+ });
76
+ });
77
+ it('should remove all event listeners on destroy', () => {
78
+ const ws = WaveSurfer.create({ container });
79
+ const clickHandler = jest.fn();
80
+ const timeUpdateHandler = jest.fn();
81
+ ws.on('click', clickHandler);
82
+ ws.on('timeupdate', timeUpdateHandler);
83
+ ws.destroy();
84
+ // After destroy, handlers should be removed
85
+ // We can't test emit directly as it's protected, but we verified
86
+ // the cleanup happened via destroy()
87
+ expect(clickHandler).not.toHaveBeenCalled();
88
+ expect(timeUpdateHandler).not.toHaveBeenCalled();
89
+ });
90
+ it('should cleanup DOM elements on destroy', () => {
91
+ const ws = WaveSurfer.create({ container });
92
+ const childCountBefore = container.children.length;
93
+ expect(childCountBefore).toBeGreaterThan(0);
94
+ ws.destroy();
95
+ const childCountAfter = container.children.length;
96
+ expect(childCountAfter).toBe(0);
97
+ });
98
+ it('should cleanup reactive subscriptions on destroy', () => {
99
+ const ws = WaveSurfer.create({ container });
100
+ // Get state to check reactive cleanup
101
+ const state = ws.getState();
102
+ // State should have reactive signals
103
+ expect(state).toBeDefined();
104
+ expect(state.isPlaying).toBeDefined();
105
+ expect(state.currentTime).toBeDefined();
106
+ ws.destroy();
107
+ // After destroy, reactive subscriptions should be cleaned up
108
+ expect(state).toBeDefined();
109
+ });
110
+ });
111
+ describe('Plugin lifecycle', () => {
112
+ it('should track registered plugins', () => {
113
+ const ws = WaveSurfer.create({ container });
114
+ // WaveSurfer should start with no plugins
115
+ expect(ws).toBeDefined();
116
+ ws.destroy();
117
+ });
118
+ it('should remove plugin elements from DOM on destroy', () => {
119
+ WaveSurfer.create({ container });
120
+ // Mock a plugin that adds DOM elements
121
+ const pluginElement = document.createElement('div');
122
+ pluginElement.className = 'test-plugin';
123
+ container.appendChild(pluginElement);
124
+ const elementCountBefore = container.querySelectorAll('.test-plugin').length;
125
+ expect(elementCountBefore).toBe(1);
126
+ // Plugin should cleanup its elements
127
+ pluginElement.remove();
128
+ const elementCountAfter = container.querySelectorAll('.test-plugin').length;
129
+ expect(elementCountAfter).toBe(0);
130
+ });
131
+ });
132
+ describe('Regions plugin memory leak (#4243)', () => {
133
+ it('should cleanup region event listeners when removed', () => {
134
+ const ws = WaveSurfer.create({ container });
135
+ const regions = ws.registerPlugin(RegionsPlugin.create());
136
+ // Mock duration so regions are saved immediately
137
+ jest.spyOn(ws, 'getDuration').mockReturnValue(10);
138
+ jest.spyOn(ws, 'getDecodedData').mockReturnValue({ numberOfChannels: 1 });
139
+ // Create a region
140
+ const region = regions.addRegion({ start: 0, end: 1 });
141
+ // Track if cleanup is happening
142
+ const clickHandler = jest.fn();
143
+ region.on('click', clickHandler);
144
+ // Remove the region
145
+ region.remove();
146
+ // After removal, the region element should be null
147
+ expect(region.element).toBeNull();
148
+ // Cleanup
149
+ ws.destroy();
150
+ });
151
+ it('should not retain regions in memory after removal', () => {
152
+ const ws = WaveSurfer.create({ container });
153
+ const regions = ws.registerPlugin(RegionsPlugin.create());
154
+ // Mock duration so regions are saved immediately
155
+ jest.spyOn(ws, 'getDuration').mockReturnValue(10);
156
+ jest.spyOn(ws, 'getDecodedData').mockReturnValue({ numberOfChannels: 1 });
157
+ // Create multiple regions
158
+ const region1 = regions.addRegion({ start: 0, end: 1 });
159
+ const region2 = regions.addRegion({ start: 2, end: 3 });
160
+ const region3 = regions.addRegion({ start: 4, end: 5 });
161
+ expect(regions.getRegions().length).toBe(3);
162
+ // Remove regions
163
+ region1.remove();
164
+ region2.remove();
165
+ // Only one region should remain
166
+ expect(regions.getRegions().length).toBe(1);
167
+ expect(regions.getRegions()[0]).toBe(region3);
168
+ // Remove last region
169
+ region3.remove();
170
+ expect(regions.getRegions().length).toBe(0);
171
+ // Cleanup
172
+ ws.destroy();
173
+ });
174
+ it('should cleanup content event listeners when region is removed', () => {
175
+ const ws = WaveSurfer.create({ container });
176
+ const regions = ws.registerPlugin(RegionsPlugin.create());
177
+ // Mock duration so regions are saved immediately
178
+ jest.spyOn(ws, 'getDuration').mockReturnValue(10);
179
+ jest.spyOn(ws, 'getDecodedData').mockReturnValue({ numberOfChannels: 1 });
180
+ // Create a region with editable content
181
+ const region = regions.addRegion({
182
+ start: 0,
183
+ end: 1,
184
+ content: 'Test content',
185
+ contentEditable: true,
186
+ });
187
+ // Remove the region
188
+ region.remove();
189
+ // Content should be cleaned up
190
+ expect(region.element).toBeNull();
191
+ // Cleanup
192
+ ws.destroy();
193
+ });
194
+ it('should cleanup DOM event streams on region removal', () => {
195
+ const ws = WaveSurfer.create({ container });
196
+ const regions = ws.registerPlugin(RegionsPlugin.create());
197
+ // Mock duration so regions are saved immediately
198
+ jest.spyOn(ws, 'getDuration').mockReturnValue(10);
199
+ jest.spyOn(ws, 'getDecodedData').mockReturnValue({ numberOfChannels: 1 });
200
+ // Create regions
201
+ const createdRegions = [];
202
+ for (let i = 0; i < 10; i++) {
203
+ createdRegions.push(regions.addRegion({ start: i, end: i + 1 }));
204
+ }
205
+ expect(regions.getRegions().length).toBe(10);
206
+ // Remove all regions
207
+ createdRegions.forEach((r) => r.remove());
208
+ // All regions should be removed
209
+ expect(regions.getRegions().length).toBe(0);
210
+ // Cleanup
211
+ ws.destroy();
212
+ });
213
+ });
214
+ describe('Event listener cleanup', () => {
215
+ it('should properly cleanup on destroy', () => {
216
+ const ws = WaveSurfer.create({ container });
217
+ // Get renderer to ensure it's initialized
218
+ const renderer = ws.getRenderer();
219
+ expect(renderer).toBeDefined();
220
+ // Should not throw during destroy
221
+ expect(() => {
222
+ ws.destroy();
223
+ }).not.toThrow();
224
+ });
225
+ });
226
+ describe('Reactive system cleanup', () => {
227
+ it('should have reactive state available', () => {
228
+ const ws = WaveSurfer.create({ container });
229
+ const state = ws.getState();
230
+ // State should expose reactive signals
231
+ expect(state.isPlaying).toBeDefined();
232
+ expect(state.currentTime).toBeDefined();
233
+ expect(state.duration).toBeDefined();
234
+ expect(state.volume).toBeDefined();
235
+ expect(state.progressPercent).toBeDefined();
236
+ // Cleanup
237
+ ws.destroy();
238
+ });
239
+ it('should not accumulate subscriptions across instances', () => {
240
+ const instances = [];
241
+ // Create multiple instances
242
+ for (let i = 0; i < 5; i++) {
243
+ const ws = WaveSurfer.create({ container });
244
+ instances.push(ws);
245
+ }
246
+ // Each instance should be independent
247
+ expect(instances.length).toBe(5);
248
+ // Destroy all instances
249
+ instances.forEach((ws) => ws.destroy());
250
+ // All instances should be cleaned up
251
+ expect(instances.every((ws) => ws !== null)).toBe(true);
252
+ });
253
+ });
254
+ describe('Edge cases', () => {
255
+ it('should handle destroy called multiple times', () => {
256
+ const ws = WaveSurfer.create({ container });
257
+ // Should not throw when destroyed multiple times
258
+ expect(() => {
259
+ ws.destroy();
260
+ ws.destroy();
261
+ ws.destroy();
262
+ }).not.toThrow();
263
+ });
264
+ it('should handle destroy without initialization', () => {
265
+ const ws = WaveSurfer.create({ container });
266
+ // Destroy immediately without loading audio
267
+ expect(() => {
268
+ ws.destroy();
269
+ }).not.toThrow();
270
+ });
271
+ it('should cleanup even if events are subscribed during destroy', () => {
272
+ const ws = WaveSurfer.create({ container });
273
+ // Subscribe to destroy event
274
+ const destroyHandler = jest.fn();
275
+ ws.on('destroy', destroyHandler);
276
+ ws.destroy();
277
+ // Destroy handler should have been called
278
+ expect(destroyHandler).toHaveBeenCalled();
279
+ });
280
+ });
281
+ });
@@ -60,4 +60,111 @@ describe('Player', () => {
60
60
  yield player.setSinkId('id');
61
61
  expect(media.setSinkId).toHaveBeenCalledWith('id');
62
62
  }));
63
+ describe('reactive signals', () => {
64
+ test('exposes isPlayingSignal', () => {
65
+ const media = createMedia();
66
+ const player = new Player({ media });
67
+ expect(player.isPlayingSignal).toBeDefined();
68
+ expect(player.isPlayingSignal.value).toBe(false);
69
+ });
70
+ test('exposes currentTimeSignal', () => {
71
+ const media = createMedia();
72
+ const player = new Player({ media });
73
+ expect(player.currentTimeSignal).toBeDefined();
74
+ expect(player.currentTimeSignal.value).toBe(0);
75
+ });
76
+ test('exposes durationSignal', () => {
77
+ const media = createMedia();
78
+ const player = new Player({ media });
79
+ expect(player.durationSignal).toBeDefined();
80
+ expect(typeof player.durationSignal.value).toBe('number');
81
+ });
82
+ test('exposes volumeSignal', () => {
83
+ const media = createMedia();
84
+ const player = new Player({ media });
85
+ expect(player.volumeSignal).toBeDefined();
86
+ expect(typeof player.volumeSignal.value).toBe('number');
87
+ });
88
+ test('exposes mutedSignal', () => {
89
+ const media = createMedia();
90
+ const player = new Player({ media });
91
+ expect(player.mutedSignal).toBeDefined();
92
+ expect(typeof player.mutedSignal.value).toBe('boolean');
93
+ });
94
+ test('exposes playbackRateSignal', () => {
95
+ const media = createMedia();
96
+ const player = new Player({ media });
97
+ expect(player.playbackRateSignal).toBeDefined();
98
+ expect(typeof player.playbackRateSignal.value).toBe('number');
99
+ });
100
+ test('exposes seekingSignal', () => {
101
+ const media = createMedia();
102
+ const player = new Player({ media });
103
+ expect(player.seekingSignal).toBeDefined();
104
+ expect(player.seekingSignal.value).toBe(false);
105
+ });
106
+ test('isPlayingSignal updates on play event', () => {
107
+ const media = createMedia();
108
+ const player = new Player({ media });
109
+ expect(player.isPlayingSignal.value).toBe(false);
110
+ media.dispatchEvent(new Event('play'));
111
+ expect(player.isPlayingSignal.value).toBe(true);
112
+ });
113
+ test('isPlayingSignal updates on pause event', () => {
114
+ const media = createMedia();
115
+ const player = new Player({ media });
116
+ media.dispatchEvent(new Event('play'));
117
+ expect(player.isPlayingSignal.value).toBe(true);
118
+ media.dispatchEvent(new Event('pause'));
119
+ expect(player.isPlayingSignal.value).toBe(false);
120
+ });
121
+ test('isPlayingSignal updates on ended event', () => {
122
+ const media = createMedia();
123
+ const player = new Player({ media });
124
+ media.dispatchEvent(new Event('play'));
125
+ expect(player.isPlayingSignal.value).toBe(true);
126
+ media.dispatchEvent(new Event('ended'));
127
+ expect(player.isPlayingSignal.value).toBe(false);
128
+ });
129
+ test('currentTimeSignal updates on timeupdate event', () => {
130
+ const media = createMedia();
131
+ Object.defineProperty(media, 'currentTime', { configurable: true, value: 5.5, writable: true });
132
+ const player = new Player({ media });
133
+ expect(player.currentTimeSignal.value).toBe(0);
134
+ media.dispatchEvent(new Event('timeupdate'));
135
+ expect(player.currentTimeSignal.value).toBe(5.5);
136
+ });
137
+ test('durationSignal updates on durationchange event', () => {
138
+ const media = createMedia();
139
+ Object.defineProperty(media, 'duration', { configurable: true, value: 120.5, writable: true });
140
+ const player = new Player({ media });
141
+ media.dispatchEvent(new Event('durationchange'));
142
+ expect(player.durationSignal.value).toBe(120.5);
143
+ });
144
+ test('seekingSignal updates on seeking and seeked events', () => {
145
+ const media = createMedia();
146
+ const player = new Player({ media });
147
+ expect(player.seekingSignal.value).toBe(false);
148
+ media.dispatchEvent(new Event('seeking'));
149
+ expect(player.seekingSignal.value).toBe(true);
150
+ media.dispatchEvent(new Event('seeked'));
151
+ expect(player.seekingSignal.value).toBe(false);
152
+ });
153
+ test('volumeSignal and mutedSignal update on volumechange event', () => {
154
+ const media = createMedia();
155
+ const player = new Player({ media });
156
+ Object.defineProperty(media, 'volume', { configurable: true, value: 0.7, writable: true });
157
+ Object.defineProperty(media, 'muted', { configurable: true, value: true, writable: true });
158
+ media.dispatchEvent(new Event('volumechange'));
159
+ expect(player.volumeSignal.value).toBe(0.7);
160
+ expect(player.mutedSignal.value).toBe(true);
161
+ });
162
+ test('playbackRateSignal updates on ratechange event', () => {
163
+ const media = createMedia();
164
+ const player = new Player({ media });
165
+ Object.defineProperty(media, 'playbackRate', { configurable: true, value: 1.5, writable: true });
166
+ media.dispatchEvent(new Event('ratechange'));
167
+ expect(player.playbackRateSignal.value).toBe(1.5);
168
+ });
169
+ });
63
170
  });
@@ -29,6 +29,7 @@ describe('renderer-utils', () => {
29
29
  barRadius: 3,
30
30
  barIndexScale: 100 / ((4 + 2) * 10),
31
31
  barSpacing: 6,
32
+ barMinHeight: 0,
32
33
  });
33
34
  });
34
35
  });
@@ -47,6 +48,23 @@ describe('renderer-utils', () => {
47
48
  vScale: 1,
48
49
  })).toEqual({ topHeight: 0, totalHeight: 1 });
49
50
  });
51
+ it('ensures total height is at least barMinHeight', () => {
52
+ expect(calculateBarHeights({
53
+ maxTop: 0,
54
+ maxBottom: 0,
55
+ halfHeight: 20,
56
+ vScale: 1,
57
+ barMinHeight: 10,
58
+ })).toEqual({ topHeight: 5, totalHeight: 10 });
59
+ expect(calculateBarHeights({
60
+ maxTop: 0,
61
+ maxBottom: 0,
62
+ halfHeight: 20,
63
+ vScale: 1,
64
+ barMinHeight: 10,
65
+ barAlign: 'top',
66
+ })).toEqual({ topHeight: 0, totalHeight: 10 });
67
+ });
50
68
  });
51
69
  describe('resolveBarYPosition', () => {
52
70
  const baseArgs = {
@@ -85,6 +103,7 @@ describe('renderer-utils', () => {
85
103
  vScale: 1,
86
104
  canvasHeight: 40,
87
105
  barAlign: undefined,
106
+ barMinHeight: 0,
88
107
  });
89
108
  expect(segments).toEqual([
90
109
  { x: 0, y: 8, width: 1, height: 3 },
@@ -95,6 +114,33 @@ describe('renderer-utils', () => {
95
114
  { x: 5, y: 0, width: 1, height: 16 },
96
115
  ]);
97
116
  });
117
+ it('ensures bars are at least barMinHeight tall', () => {
118
+ const height = 40;
119
+ const length = 10;
120
+ const { barIndexScale, barSpacing, barWidth, halfHeight } = calculateBarRenderConfig({
121
+ width: 100,
122
+ height,
123
+ length,
124
+ options,
125
+ pixelRatio: 1,
126
+ });
127
+ const segments = calculateBarSegments({
128
+ channelData: [
129
+ new Float32Array(length).fill(0.001), // Very small values
130
+ ],
131
+ barIndexScale,
132
+ barSpacing,
133
+ barWidth,
134
+ halfHeight,
135
+ vScale: 1,
136
+ canvasHeight: height / 2,
137
+ barAlign: undefined,
138
+ barMinHeight: 10,
139
+ });
140
+ expect(segments.length).toBeGreaterThan(0);
141
+ expect(segments[0].height).toBe(10);
142
+ expect(segments[0].y).toBe(15); // Centered: 20 - 10/2
143
+ });
98
144
  });
99
145
  describe('getRelativePointerPosition', () => {
100
146
  it('returns pointer coordinates as relative offsets', () => {
@@ -302,12 +348,12 @@ describe('renderer-utils', () => {
302
348
  });
303
349
  });
304
350
  describe('calculateScrollPercentages', () => {
305
- it('returns zero percentages when scroll width is zero', () => {
351
+ it('returns full range when scroll width is zero', () => {
306
352
  expect(calculateScrollPercentages({
307
353
  scrollLeft: 0,
308
354
  clientWidth: 100,
309
355
  scrollWidth: 0,
310
- })).toEqual({ startX: 0, endX: 0 });
356
+ })).toEqual({ startX: 0, endX: 1 });
311
357
  });
312
358
  it('returns start and end ratios relative to scroll width', () => {
313
359
  expect(calculateScrollPercentages({
@@ -316,5 +362,12 @@ describe('renderer-utils', () => {
316
362
  scrollWidth: 400,
317
363
  })).toEqual({ startX: 0.125, endX: 0.375 });
318
364
  });
365
+ it('clamps values to 0-1 range', () => {
366
+ expect(calculateScrollPercentages({
367
+ scrollLeft: -10,
368
+ clientWidth: 100,
369
+ scrollWidth: 400,
370
+ })).toEqual({ startX: 0, endX: 0.225 });
371
+ });
319
372
  });
320
373
  });
@@ -1 +1,5 @@
1
+ /**
2
+ * @deprecated Use createDragStream from './reactive/drag-stream.js' instead.
3
+ * This function is maintained for backward compatibility but will be removed in a future version.
4
+ */
1
5
  export declare function makeDraggable(element: HTMLElement | null, onDrag: (dx: number, dy: number, x: number, y: number) => void, onStart?: (x: number, y: number) => void, onEnd?: (x: number, y: number) => void, threshold?: number, mouseButton?: number, touchDelay?: number): () => void;
package/dist/draggable.js CHANGED
@@ -1,3 +1,7 @@
1
+ /**
2
+ * @deprecated Use createDragStream from './reactive/drag-stream.js' instead.
3
+ * This function is maintained for backward compatibility but will be removed in a future version.
4
+ */
1
5
  export function makeDraggable(element, onDrag, onStart, onEnd, threshold = 3, mouseButton = 0, touchDelay = 100) {
2
6
  if (!element)
3
7
  return () => void 0;
package/dist/player.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import EventEmitter, { type GeneralEventTypes } from './event-emitter.js';
2
+ import { type WritableSignal } from './reactive/store.js';
2
3
  type PlayerOptions = {
3
4
  media?: HTMLMediaElement;
4
5
  mediaControls?: boolean;
@@ -8,7 +9,27 @@ type PlayerOptions = {
8
9
  declare class Player<T extends GeneralEventTypes> extends EventEmitter<T> {
9
10
  protected media: HTMLMediaElement;
10
11
  private isExternalMedia;
12
+ private _isPlaying;
13
+ private _currentTime;
14
+ private _duration;
15
+ private _volume;
16
+ private _muted;
17
+ private _playbackRate;
18
+ private _seeking;
19
+ private reactiveMediaEventCleanups;
20
+ get isPlayingSignal(): WritableSignal<boolean>;
21
+ get currentTimeSignal(): WritableSignal<number>;
22
+ get durationSignal(): WritableSignal<number>;
23
+ get volumeSignal(): WritableSignal<number>;
24
+ get mutedSignal(): WritableSignal<boolean>;
25
+ get playbackRateSignal(): WritableSignal<number>;
26
+ get seekingSignal(): WritableSignal<boolean>;
11
27
  constructor(options: PlayerOptions);
28
+ /**
29
+ * Setup reactive media event handlers that update signals
30
+ * This bridges the imperative HTMLMediaElement API to reactive state
31
+ */
32
+ private setupReactiveMediaEvents;
12
33
  protected onMediaEvent<K extends keyof HTMLElementEventMap>(event: K, callback: (ev: HTMLElementEventMap[K]) => void, options?: boolean | AddEventListenerOptions): () => void;
13
34
  protected getSrc(): string;
14
35
  private revokeSrc;