@freqhole/playlistz 0.0.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 (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,547 @@
1
+ // Test setup for playlistz component tests
2
+ // This file sets up jsdom environment and IndexedDB mocking
3
+
4
+ import { vi } from "vitest";
5
+ import "fake-indexeddb/auto";
6
+
7
+ // Configure globals for jsdom environment
8
+ global.ResizeObserver = vi.fn(() => ({
9
+ observe: vi.fn(),
10
+ unobserve: vi.fn(),
11
+ disconnect: vi.fn(),
12
+ }));
13
+
14
+ global.IntersectionObserver = vi.fn().mockImplementation(() => ({
15
+ observe: vi.fn(),
16
+ unobserve: vi.fn(),
17
+ disconnect: vi.fn(),
18
+ root: null,
19
+ rootMargin: "0px",
20
+ thresholds: [],
21
+ takeRecords: vi.fn(() => []),
22
+ })) as any;
23
+
24
+ // Mock BroadcastChannel for tests
25
+ global.BroadcastChannel = vi.fn(() => ({
26
+ postMessage: vi.fn(),
27
+ onmessage: null,
28
+ close: vi.fn(),
29
+ addEventListener: vi.fn(),
30
+ removeEventListener: vi.fn(),
31
+ dispatchEvent: vi.fn(),
32
+ })) as any;
33
+
34
+ // Mock crypto.randomUUID and crypto.subtle
35
+ Object.defineProperty(global, "crypto", {
36
+ value: {
37
+ randomUUID: vi.fn(
38
+ () => `test-uuid-${Math.random().toString(36).substr(2, 9)}`
39
+ ),
40
+ getRandomValues: vi.fn((arr) => {
41
+ for (let i = 0; i < arr.length; i++) {
42
+ arr[i] = Math.floor(Math.random() * 256);
43
+ }
44
+ return arr;
45
+ }),
46
+ subtle: {
47
+ digest: vi.fn().mockImplementation((_algorithm, _data) => {
48
+ // Mock SHA-256 digest - return a fixed hash for testing
49
+ const mockHash = new Uint8Array(32); // SHA-256 produces 32 bytes
50
+ for (let i = 0; i < 32; i++) {
51
+ mockHash[i] = i; // Simple pattern for testing
52
+ }
53
+ return Promise.resolve(mockHash.buffer);
54
+ }),
55
+ },
56
+ },
57
+ writable: true,
58
+ });
59
+
60
+ // Mock URL.createObjectURL and revokeObjectURL for file handling
61
+ global.URL.createObjectURL = vi.fn(() => "blob:mock-url");
62
+ global.URL.revokeObjectURL = vi.fn();
63
+
64
+ // Mock Cache API - create consistent cache instance
65
+ const createMockCache = () => ({
66
+ match: vi.fn(),
67
+ add: vi.fn().mockResolvedValue(undefined),
68
+ addAll: vi.fn().mockResolvedValue(undefined),
69
+ put: vi.fn().mockResolvedValue(undefined),
70
+ delete: vi.fn().mockResolvedValue(true),
71
+ keys: vi.fn().mockResolvedValue([]),
72
+ });
73
+
74
+ const mockCache = createMockCache();
75
+
76
+ const mockCaches = {
77
+ open: vi.fn().mockResolvedValue(mockCache),
78
+ delete: vi.fn().mockResolvedValue(true),
79
+ keys: vi.fn().mockResolvedValue(["playlistz-cache-v1"]),
80
+ match: vi.fn(),
81
+ has: vi.fn().mockResolvedValue(true),
82
+ };
83
+
84
+ Object.defineProperty(global, "caches", {
85
+ value: mockCaches,
86
+ writable: true,
87
+ });
88
+
89
+ // Export mock objects for direct access in tests
90
+ (global as any).__mockCache = mockCache;
91
+ (global as any).__mockCaches = mockCaches;
92
+
93
+ // Mock management functions
94
+ export const mockManager = {
95
+ // Reset all mocks to default state
96
+ resetAllMocks() {
97
+ vi.clearAllMocks();
98
+
99
+ // Reset cache mocks
100
+ mockCache.match.mockResolvedValue(undefined);
101
+ mockCache.add.mockResolvedValue(undefined);
102
+ mockCache.addAll.mockResolvedValue(undefined);
103
+ mockCache.put.mockResolvedValue(undefined);
104
+ mockCache.delete.mockResolvedValue(true);
105
+ mockCache.keys.mockResolvedValue([]);
106
+
107
+ // Reset caches API mock
108
+ mockCaches.open.mockResolvedValue(mockCache);
109
+ mockCaches.delete.mockResolvedValue(true);
110
+ mockCaches.keys.mockResolvedValue(["playlistz-cache-v1"]);
111
+
112
+ // Reset storage API mock
113
+ mockNavigatorStorage.persist.mockResolvedValue(true);
114
+ mockNavigatorStorage.persisted.mockResolvedValue(true);
115
+ mockNavigatorStorage.estimate.mockResolvedValue({
116
+ quota: 1000000000,
117
+ usage: 100000000,
118
+ });
119
+
120
+ // Reset service worker mock
121
+ mockServiceWorker.controller = null;
122
+ mockServiceWorker.register.mockResolvedValue({
123
+ active: null,
124
+ installing: null,
125
+ waiting: null,
126
+ update: vi.fn().mockResolvedValue(undefined),
127
+ unregister: vi.fn().mockResolvedValue(true),
128
+ });
129
+ },
130
+
131
+ // Reset global API availability
132
+ resetGlobalAPIs() {
133
+ // Reset navigator properties safely
134
+ try {
135
+ Object.defineProperty(global.navigator, "storage", {
136
+ value: mockNavigatorStorage,
137
+ writable: true,
138
+ configurable: true,
139
+ });
140
+ } catch {
141
+ // Property already exists and is not configurable
142
+ (global.navigator as any).storage = mockNavigatorStorage;
143
+ }
144
+
145
+ try {
146
+ Object.defineProperty(global.navigator, "serviceWorker", {
147
+ value: mockServiceWorker,
148
+ writable: true,
149
+ configurable: true,
150
+ });
151
+ } catch {
152
+ // Property already exists and is not configurable
153
+ (global.navigator as any).serviceWorker = mockServiceWorker;
154
+ }
155
+
156
+ try {
157
+ Object.defineProperty(global.navigator, "onLine", {
158
+ value: true,
159
+ writable: true,
160
+ configurable: true,
161
+ });
162
+ } catch {
163
+ // Property already exists and is not configurable
164
+ (global.navigator as any).onLine = true;
165
+ }
166
+
167
+ // Reset caches API safely
168
+ try {
169
+ Object.defineProperty(global, "caches", {
170
+ value: mockCaches,
171
+ writable: true,
172
+ configurable: true,
173
+ });
174
+ } catch {
175
+ // Property already exists and is not configurable
176
+ (global as any).caches = mockCaches;
177
+ }
178
+
179
+ // Reset window properties safely
180
+ try {
181
+ Object.defineProperty(global.window, "caches", {
182
+ value: mockCaches,
183
+ writable: true,
184
+ configurable: true,
185
+ });
186
+ } catch {
187
+ // Property already exists and is not configurable
188
+ (global.window as any).caches = mockCaches;
189
+ }
190
+ },
191
+
192
+ // Mock API as unavailable
193
+ mockAPIUnavailable: {
194
+ storage() {
195
+ Object.defineProperty(global.navigator, "storage", {
196
+ value: undefined,
197
+ writable: true,
198
+ configurable: true,
199
+ });
200
+ },
201
+
202
+ caches() {
203
+ Object.defineProperty(global, "caches", {
204
+ value: undefined,
205
+ writable: true,
206
+ configurable: true,
207
+ });
208
+ Object.defineProperty(global.window, "caches", {
209
+ value: undefined,
210
+ writable: true,
211
+ configurable: true,
212
+ });
213
+ },
214
+
215
+ serviceWorker() {
216
+ Object.defineProperty(global.navigator, "serviceWorker", {
217
+ value: undefined,
218
+ writable: true,
219
+ configurable: true,
220
+ });
221
+ },
222
+ },
223
+
224
+ // Get mock references
225
+ getMocks() {
226
+ return {
227
+ cache: mockCache,
228
+ caches: mockCaches,
229
+ navigatorStorage: mockNavigatorStorage,
230
+ serviceWorker: mockServiceWorker,
231
+ };
232
+ },
233
+ };
234
+
235
+ // Mock Service Worker
236
+ const mockServiceWorkerRegistration = {
237
+ active: null,
238
+ installing: null,
239
+ waiting: null,
240
+ update: vi.fn().mockResolvedValue(undefined),
241
+ unregister: vi.fn().mockResolvedValue(true),
242
+ };
243
+
244
+ const mockServiceWorker = {
245
+ controller: null,
246
+ ready: Promise.resolve(mockServiceWorkerRegistration),
247
+ register: vi.fn().mockResolvedValue(mockServiceWorkerRegistration),
248
+ addEventListener: vi.fn(),
249
+ removeEventListener: vi.fn(),
250
+ getRegistration: vi.fn().mockResolvedValue(mockServiceWorkerRegistration),
251
+ };
252
+
253
+ // Mock Storage API
254
+ const mockNavigatorStorage = {
255
+ persist: vi.fn().mockResolvedValue(true),
256
+ persisted: vi.fn().mockResolvedValue(true),
257
+ estimate: vi.fn().mockResolvedValue({
258
+ quota: 1000000000, // 1GB
259
+ usage: 100000000, // 100MB
260
+ }),
261
+ };
262
+
263
+ // Mock MediaMetadata for media session
264
+ global.MediaMetadata = vi.fn().mockImplementation((metadata) => ({
265
+ title: metadata?.title || "",
266
+ artist: metadata?.artist || "",
267
+ album: metadata?.album || "",
268
+ artwork: metadata?.artwork || [],
269
+ }));
270
+
271
+ // Enhanced Blob mock with arrayBuffer method
272
+ const originalBlob = global.Blob;
273
+ global.Blob = vi.fn().mockImplementation((...args) => {
274
+ const blob = new originalBlob(...args);
275
+
276
+ // Add arrayBuffer method if missing
277
+ if (!blob.arrayBuffer) {
278
+ Object.defineProperty(blob, "arrayBuffer", {
279
+ value: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
280
+ writable: true,
281
+ configurable: true,
282
+ });
283
+ }
284
+
285
+ return blob;
286
+ }) as any;
287
+
288
+ // Ensure global Blob constructor maintains prototype
289
+ Object.setPrototypeOf(global.Blob, originalBlob);
290
+
291
+ // Mock Navigator API with all needed properties
292
+ Object.defineProperty(global, "navigator", {
293
+ value: {
294
+ ...global.navigator,
295
+ onLine: true,
296
+ serviceWorker: mockServiceWorker,
297
+ storage: mockNavigatorStorage,
298
+ mediaSession: {
299
+ setActionHandler: vi.fn(),
300
+ setPositionState: vi.fn(),
301
+ metadata: null,
302
+ playbackState: "none",
303
+ },
304
+ },
305
+ writable: true,
306
+ });
307
+
308
+ // Mock FileReader for audio metadata extraction
309
+ global.FileReader = vi.fn(() => ({
310
+ readAsArrayBuffer: vi.fn(function (this: any) {
311
+ // Simulate async file reading
312
+ setTimeout(() => {
313
+ this.onload?.({ target: { result: new ArrayBuffer(8) } });
314
+ }, 0);
315
+ }),
316
+ onload: null,
317
+ onerror: null,
318
+ result: null,
319
+ })) as any;
320
+
321
+ // Mock Audio constructor for audio file testing
322
+ global.Audio = vi.fn(() => {
323
+ const eventListeners = new Map();
324
+
325
+ const mockAudio = {
326
+ addEventListener: vi.fn((event, handler) => {
327
+ if (!eventListeners.has(event)) {
328
+ eventListeners.set(event, []);
329
+ }
330
+ eventListeners.get(event).push(handler);
331
+ }),
332
+ removeEventListener: vi.fn((event, handler) => {
333
+ if (eventListeners.has(event)) {
334
+ const handlers = eventListeners.get(event);
335
+ const index = handlers.indexOf(handler);
336
+ if (index > -1) {
337
+ handlers.splice(index, 1);
338
+ }
339
+ }
340
+ }),
341
+ load: vi.fn(),
342
+ play: vi.fn(() => {
343
+ mockAudio.paused = false;
344
+ // Fire play event synchronously
345
+ const playHandlers = eventListeners.get("play") || [];
346
+ playHandlers.forEach((handler: any) => handler());
347
+ return Promise.resolve();
348
+ }),
349
+ pause: vi.fn(() => {
350
+ mockAudio.paused = true;
351
+ // Fire pause event synchronously
352
+ const pauseHandlers = eventListeners.get("pause") || [];
353
+ pauseHandlers.forEach((handler: any) => handler());
354
+ }),
355
+ currentTime: 0,
356
+ duration: 180, // Default 3 minutes
357
+ src: "",
358
+ volume: 1,
359
+ muted: false,
360
+ paused: true,
361
+ ended: false,
362
+ readyState: 4,
363
+ networkState: 1,
364
+ error: null,
365
+ preload: "metadata",
366
+ _volume: 1,
367
+ _currentTime: 0,
368
+ _src: "",
369
+ };
370
+
371
+ // Make properties writable so the service can set them
372
+ Object.defineProperty(mockAudio, "volume", {
373
+ get() {
374
+ return mockAudio._volume;
375
+ },
376
+ set(value) {
377
+ mockAudio._volume = value;
378
+ },
379
+ enumerable: true,
380
+ configurable: true,
381
+ });
382
+
383
+ Object.defineProperty(mockAudio, "currentTime", {
384
+ get() {
385
+ return mockAudio._currentTime;
386
+ },
387
+ set(value) {
388
+ mockAudio._currentTime = value;
389
+ },
390
+ enumerable: true,
391
+ configurable: true,
392
+ });
393
+
394
+ Object.defineProperty(mockAudio, "src", {
395
+ get() {
396
+ return mockAudio._src;
397
+ },
398
+ set(value) {
399
+ mockAudio._src = value;
400
+ // Simulate events when src is set - fire synchronously
401
+ if (value) {
402
+ const loadstartHandlers = eventListeners.get("loadstart") || [];
403
+ loadstartHandlers.forEach((handler: any) => handler());
404
+
405
+ // Also fire loadedmetadata and canplay events
406
+ const loadedmetadataHandlers =
407
+ eventListeners.get("loadedmetadata") || [];
408
+ loadedmetadataHandlers.forEach((handler: any) => handler());
409
+
410
+ const canplayHandlers = eventListeners.get("canplay") || [];
411
+ canplayHandlers.forEach((handler: any) => handler());
412
+ }
413
+ },
414
+ enumerable: true,
415
+ configurable: true,
416
+ });
417
+
418
+ return mockAudio;
419
+ }) as any;
420
+
421
+ // Mock document API for canvas operations and DOM manipulation
422
+ Object.defineProperty(global, "document", {
423
+ value: {
424
+ ...global.document,
425
+ addEventListener: global.document.addEventListener.bind(global.document),
426
+ removeEventListener: global.document.removeEventListener.bind(global.document),
427
+ dispatchEvent: global.document.dispatchEvent.bind(global.document),
428
+ title: "Test Page",
429
+ querySelector: vi.fn(() => null),
430
+ querySelectorAll: vi.fn(() => []),
431
+ createElement: vi.fn(() => ({
432
+ width: 0,
433
+ height: 0,
434
+ setAttribute: vi.fn(),
435
+ remove: vi.fn(),
436
+ getContext: vi.fn(() => ({
437
+ drawImage: vi.fn(),
438
+ })),
439
+ toBlob: vi.fn((callback) => {
440
+ const blob = new Blob(["mock-image-data"], { type: "image/png" });
441
+ callback(blob);
442
+ }),
443
+ })),
444
+ head: {
445
+ appendChild: vi.fn(),
446
+ },
447
+ },
448
+ writable: true,
449
+ });
450
+
451
+ // Mock window object with needed properties
452
+ Object.defineProperty(global, "window", {
453
+ value: {
454
+ ...global.window,
455
+ addEventListener: vi.fn(),
456
+ removeEventListener: vi.fn(),
457
+ dispatchEvent: vi.fn(),
458
+ location: {
459
+ href: "http://localhost:3000/test",
460
+ protocol: "http:",
461
+ host: "localhost:3000",
462
+ origin: "http://localhost:3000",
463
+ },
464
+ caches: mockCaches,
465
+ navigator: global.navigator,
466
+ },
467
+ writable: true,
468
+ });
469
+
470
+ // Mock window.matchMedia (only in jsdom environment)
471
+ if (typeof window !== "undefined") {
472
+ Object.defineProperty(window, "matchMedia", {
473
+ writable: true,
474
+ value: vi.fn().mockImplementation((query) => ({
475
+ matches: false,
476
+ media: query,
477
+ onchange: null,
478
+ addListener: vi.fn(), // deprecated
479
+ removeListener: vi.fn(), // deprecated
480
+ addEventListener: vi.fn(),
481
+ removeEventListener: vi.fn(),
482
+ dispatchEvent: vi.fn(),
483
+ })),
484
+ });
485
+ }
486
+
487
+ // Test database setup functions
488
+ export async function setupTestDB(): Promise<void> {
489
+ // fake-indexeddb automatically provides a fresh database for each test
490
+ // No additional setup needed as fake-indexeddb/auto handles this
491
+ }
492
+
493
+ export async function cleanupTestDB(): Promise<void> {
494
+ // fake-indexeddb automatically cleans up after each test
495
+ // Manual cleanup can be done here if needed
496
+ if (typeof indexedDB !== "undefined") {
497
+ // Get all database names and delete them
498
+ try {
499
+ const databases = (await indexedDB.databases?.()) || [];
500
+ for (const db of databases) {
501
+ if (db.name) {
502
+ indexedDB.deleteDatabase(db.name);
503
+ }
504
+ }
505
+ } catch {
506
+ // Some environments may not support indexedDB.databases()
507
+ // In that case, fake-indexeddb will handle cleanup automatically
508
+ }
509
+ }
510
+ }
511
+
512
+ // Helper function to create File mocks with arrayBuffer method
513
+ export function createMockFile(
514
+ content: string[] | string,
515
+ filename: string,
516
+ options: { type: string; lastModified?: number } = {
517
+ type: "application/octet-stream",
518
+ }
519
+ ): File {
520
+ const file = new File(
521
+ Array.isArray(content) ? content : [content],
522
+ filename,
523
+ options
524
+ );
525
+
526
+ // Add arrayBuffer method that File objects should have
527
+ Object.defineProperty(file, "arrayBuffer", {
528
+ value: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
529
+ writable: true,
530
+ configurable: true,
531
+ });
532
+
533
+ return file;
534
+ }
535
+
536
+ // Clean up after each test
537
+ import { afterEach } from "vitest";
538
+
539
+ afterEach(() => {
540
+ // Clear all IndexedDB databases
541
+ if (typeof indexedDB !== "undefined") {
542
+ // fake-indexeddb cleanup is automatic, but we can reset if needed
543
+ }
544
+
545
+ // Clear all mocks
546
+ vi.clearAllMocks();
547
+ });
@@ -0,0 +1,40 @@
1
+ // global type declarations for playlistz
2
+
3
+ // behaviour modes for the __mockBlobFetch dev hook
4
+ type MockBlobBehaviour =
5
+ | { type: "instant" }
6
+ | { type: "delayed"; ms: number }
7
+ | { type: "progress"; chunks: number; msPerChunk: number }
8
+ | { type: "error"; code: "not_found" | "timeout" | "peer_gone" }
9
+ | { type: "stall" };
10
+
11
+ declare global {
12
+ interface Window {
13
+ STANDALONE_MODE?: boolean;
14
+ DEFERRED_PLAYLIST_DATA?: FreqholePlaylistz;
15
+
16
+ // dev/test hooks (DEV builds only - not present in production)
17
+
18
+ // file import hook (set in components/index.tsx)
19
+ __processFiles?: (files: File[]) => Promise<void>;
20
+
21
+ // audio element control
22
+ __seekTo?: (seconds: number) => void;
23
+ __triggerTrackEnd?: () => void;
24
+ __triggerAudioError?: (code?: number) => void;
25
+ __currentSong?: () => string | null;
26
+
27
+ // blob store control
28
+ __evictBlob?: (sha256: string) => Promise<void>;
29
+ __mockBlobFetch?: (behaviour: MockBlobBehaviour) => void;
30
+ __clearMockBlobFetch?: () => void;
31
+ __setBlobFetchTimeout?: (ms: number) => void;
32
+ __fetchBlobBySha?: (sha256: string) => Promise<string | null>;
33
+
34
+ // docIndex test hooks (registered in src/dev-hooks.ts)
35
+ __getDocIndexEntries?: () => Promise<unknown[]>;
36
+ __patchDocIndexEntry?: (docId: string, patch: unknown) => Promise<void>;
37
+ }
38
+ }
39
+
40
+ export {};
@@ -0,0 +1,99 @@
1
+ export interface Playlist {
2
+ id: string; // UUID or AutomergeUrl (doc-backed)
3
+ title: string; // User-editable playlist name
4
+ description?: string; // Optional description
5
+ imageData?: ArrayBuffer; // Full-size image data (optional, populated on-demand from blob store)
6
+ thumbnailData?: ArrayBuffer; // Thumbnail image data (optional, populated on-demand)
7
+ imageType?: string; // MIME type for the image
8
+ createdAt: number; // Timestamp
9
+ updatedAt: number; // Timestamp
10
+ songIds: string[]; // Ordered array of song IDs
11
+ needsImageLoad?: boolean;
12
+ imageFilePath?: string;
13
+ rev?: number; // Revision number for standalone mode (starts at 0, incremented on download)
14
+ // internal: primary image sha for lazy blob store loading (set by docToPlaylist)
15
+ _primaryImageSha?: string;
16
+ // background image filter settings
17
+ bgFilterEnabled?: boolean; // default: true
18
+ bgFilterBlur?: number; // default: 3 (px)
19
+ bgFilterContrast?: number; // default: 3
20
+ bgFilterBrightness?: number; // default: 0.4
21
+ // cover image filter (the blurred thumbnail in the playlist header)
22
+ coverFilterEnabled?: boolean; // default: true
23
+ coverFilterBlur?: number; // default: 3 (px)
24
+ // background image layout
25
+ bgSize?: string; // css background-size, default: "cover"
26
+ bgPosition?: string; // css background-position, default: "top"
27
+ bgRepeat?: string; // css background-repeat, default: "no-repeat"
28
+ // remote source metadata: set for playlists received from a remote peer
29
+ remoteNodeId?: string; // iroh node id of the peer who shared this
30
+ remoteName?: string; // their display name at time of sync
31
+ remoteAvatarDataUrl?: string; // their avatar data url at time of sync
32
+ isForked?: boolean; // true once the user has forked to a local editable copy
33
+ }
34
+
35
+ export interface Song {
36
+ id: string; // UUID
37
+ file?: File; // Original audio file (only available during upload or when loaded for playback)
38
+ blobUrl?: string; // Object URL for audio playback (created on-demand)
39
+ audioData?: ArrayBuffer; // Audio data (legacy, no longer stored in idb)
40
+ mimeType: string; // MIME type for recreating blob from stored data
41
+ originalFilename: string; // Original filename with extension for downloads
42
+ fileSize?: number; // File size in bytes
43
+ title: string; // User-editable song title
44
+ artist: string; // User-editable artist name
45
+ album: string; // User-editable album name
46
+ duration: number; // Length in seconds
47
+ position: number; // Position within playlist (0-based)
48
+ imageData?: ArrayBuffer; // Cover art data (optional, populated on-demand from blob store)
49
+ thumbnailData?: ArrayBuffer; // Thumbnail cover art (optional, populated on-demand)
50
+ imageType?: string; // MIME type for the cover art
51
+ createdAt: number; // Timestamp
52
+ updatedAt: number; // Timestamp
53
+ playlistId: string; // Reference to parent playlist (or docId for doc-backed songs)
54
+ standaloneFilePath?: string; // Path to audio file in standalone mode
55
+ needsImageLoad?: boolean;
56
+ imageFilePath?: string;
57
+ sha?: string; // SHA-256 hash of the raw audio data (blob store key)
58
+ sha256?: string; // Alias for sha, set by doc adapter
59
+ // image refs from automerge doc (for blob store image loading)
60
+ images?: Array<{ blobId: string; isPrimary: boolean; blobType: string }>;
61
+ }
62
+
63
+ export interface AudioState {
64
+ currentSong: Song | null;
65
+ currentPlaylist: Playlist | null;
66
+ isPlaying: boolean;
67
+ currentTime: number;
68
+ duration: number;
69
+ volume: number;
70
+ currentIndex: number;
71
+ queue: Song[];
72
+ repeatMode: "none" | "one" | "all";
73
+ isShuffled: boolean;
74
+ isLoading: boolean;
75
+ }
76
+
77
+ export interface PlaylistStats {
78
+ totalSongs: number;
79
+ totalDuration: number; // in seconds
80
+ lastPlayed?: number; // timestamp
81
+ }
82
+
83
+ // For file upload processing
84
+ export interface FileUploadResult {
85
+ success: boolean;
86
+ song?: Song;
87
+ error?: string;
88
+ }
89
+
90
+ // For metadata extraction
91
+ export interface AudioMetadata {
92
+ title?: string;
93
+ artist?: string;
94
+ album?: string;
95
+ duration?: number;
96
+ coverArtData?: ArrayBuffer; // Full-size cover art data as ArrayBuffer
97
+ coverArtThumbnailData?: ArrayBuffer; // Thumbnail cover art data as ArrayBuffer (300x300)
98
+ coverArtType?: string; // MIME type for the cover art
99
+ }