@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,365 @@
1
+ // Image Service for Album Art and Playlist Covers
2
+ // Handles extraction, processing, and management of images
3
+
4
+ import type { Song, Playlist } from "../types/playlist.js";
5
+
6
+ export interface ImageProcessingResult {
7
+ success: boolean;
8
+ imageData?: ArrayBuffer;
9
+ thumbnailData?: ArrayBuffer;
10
+ error?: string;
11
+ metadata?: {
12
+ width: number;
13
+ height: number;
14
+ format: string;
15
+ size: number;
16
+ };
17
+ }
18
+
19
+ export interface ImageUrlResult {
20
+ thumbnailUrl: string;
21
+ fullSizeUrl: string;
22
+ }
23
+
24
+ export interface AlbumArtExtractionResult {
25
+ success: boolean;
26
+ albumArt?: string;
27
+ error?: string;
28
+ }
29
+
30
+ // Extract album art from audio file using ID3 tags
31
+ export async function extractAlbumArt(
32
+ file: File
33
+ ): Promise<AlbumArtExtractionResult> {
34
+ try {
35
+ // Read file as ArrayBuffer for ID3 parsing
36
+ const buffer = await file.arrayBuffer();
37
+ const view = new DataView(buffer);
38
+
39
+ // Check for ID3v2 tag (starts with "ID3")
40
+ if (buffer.byteLength < 10) {
41
+ return { success: false, error: "File too small to contain ID3 tags" };
42
+ }
43
+
44
+ const id3Header = String.fromCharCode(
45
+ view.getUint8(0),
46
+ view.getUint8(1),
47
+ view.getUint8(2)
48
+ );
49
+ if (id3Header !== "ID3") {
50
+ return { success: false, error: "No ID3v2 tag found" };
51
+ }
52
+
53
+ // Parse ID3v2 header
54
+ const majorVersion = view.getUint8(3);
55
+ // const minorVersion = view.getUint8(4);
56
+ // const flags = view.getUint8(5);
57
+
58
+ // Calculate tag size (synchsafe integer)
59
+ const tagSize =
60
+ ((view.getUint8(6) & 0x7f) << 21) |
61
+ ((view.getUint8(7) & 0x7f) << 14) |
62
+ ((view.getUint8(8) & 0x7f) << 7) |
63
+ (view.getUint8(9) & 0x7f);
64
+
65
+ let offset = 10;
66
+ const endOffset = Math.min(10 + tagSize, buffer.byteLength);
67
+
68
+ // Search for APIC frame (Attached Picture)
69
+ while (offset < endOffset - 10) {
70
+ // Read frame header
71
+ const frameId = String.fromCharCode(
72
+ view.getUint8(offset),
73
+ view.getUint8(offset + 1),
74
+ view.getUint8(offset + 2),
75
+ view.getUint8(offset + 3)
76
+ );
77
+
78
+ if (frameId === "APIC") {
79
+ // Found album art frame
80
+ const frameSize =
81
+ majorVersion === 4
82
+ ? // ID3v2.4 uses synchsafe integers
83
+ ((view.getUint8(offset + 4) & 0x7f) << 21) |
84
+ ((view.getUint8(offset + 5) & 0x7f) << 14) |
85
+ ((view.getUint8(offset + 6) & 0x7f) << 7) |
86
+ (view.getUint8(offset + 7) & 0x7f)
87
+ : // ID3v2.3 uses regular integers
88
+ (view.getUint8(offset + 4) << 24) |
89
+ (view.getUint8(offset + 5) << 16) |
90
+ (view.getUint8(offset + 6) << 8) |
91
+ view.getUint8(offset + 7);
92
+
93
+ // const frameFlags = (view.getUint8(offset + 8) << 8) | view.getUint8(offset + 9);
94
+ let frameOffset = offset + 10;
95
+
96
+ // Skip encoding byte
97
+ frameOffset++;
98
+
99
+ // Read MIME type (null-terminated)
100
+ let mimeType = "";
101
+ while (frameOffset < endOffset && view.getUint8(frameOffset) !== 0) {
102
+ mimeType += String.fromCharCode(view.getUint8(frameOffset));
103
+ frameOffset++;
104
+ }
105
+ frameOffset++; // Skip null terminator
106
+
107
+ // Skip picture type byte
108
+ frameOffset++;
109
+
110
+ // Skip description (null-terminated)
111
+ while (frameOffset < endOffset && view.getUint8(frameOffset) !== 0) {
112
+ frameOffset++;
113
+ }
114
+ frameOffset++; // Skip null terminator
115
+
116
+ // Extract image data
117
+ const imageDataSize = frameSize - (frameOffset - offset - 10);
118
+ if (
119
+ imageDataSize > 0 &&
120
+ frameOffset + imageDataSize <= buffer.byteLength
121
+ ) {
122
+ const imageData = buffer.slice(
123
+ frameOffset,
124
+ frameOffset + imageDataSize
125
+ );
126
+ const blob = new Blob([imageData], { type: mimeType });
127
+ const albumArt = URL.createObjectURL(blob);
128
+
129
+ return { success: true, albumArt };
130
+ }
131
+ }
132
+
133
+ // Move to next frame
134
+ const frameSize =
135
+ majorVersion === 4
136
+ ? ((view.getUint8(offset + 4) & 0x7f) << 21) |
137
+ ((view.getUint8(offset + 5) & 0x7f) << 14) |
138
+ ((view.getUint8(offset + 6) & 0x7f) << 7) |
139
+ (view.getUint8(offset + 7) & 0x7f)
140
+ : (view.getUint8(offset + 4) << 24) |
141
+ (view.getUint8(offset + 5) << 16) |
142
+ (view.getUint8(offset + 6) << 8) |
143
+ view.getUint8(offset + 7);
144
+
145
+ offset += 10 + frameSize;
146
+ }
147
+
148
+ return { success: false, error: "No album art found in ID3 tags" };
149
+ } catch (error) {
150
+ console.error("Error extracting album art:", error);
151
+ return {
152
+ success: false,
153
+ error: error instanceof Error ? error.message : "Unknown error",
154
+ };
155
+ }
156
+ }
157
+
158
+ // Process uploaded image file for playlist cover
159
+ export async function processPlaylistCover(
160
+ file: File
161
+ ): Promise<ImageProcessingResult> {
162
+ try {
163
+ if (!file.type.startsWith("image/")) {
164
+ return { success: false, error: "File is not an image" };
165
+ }
166
+
167
+ // Validate file size (max 10MB)
168
+ const maxSize = 10 * 1024 * 1024;
169
+ if (file.size > maxSize) {
170
+ return { success: false, error: "Image file too large (max 10MB)" };
171
+ }
172
+
173
+ // Store original image data as ArrayBuffer
174
+ const imageData = await file.arrayBuffer();
175
+
176
+ // Create image element to get dimensions and create thumbnail
177
+ const img = new Image();
178
+ const tempUrl = URL.createObjectURL(file);
179
+
180
+ return new Promise((resolve) => {
181
+ img.onload = async () => {
182
+ try {
183
+ const metadata = {
184
+ width: img.width,
185
+ height: img.height,
186
+ format: file.type,
187
+ size: file.size,
188
+ };
189
+
190
+ // Create thumbnail data (300x300 max)
191
+ const thumbnailData = await createThumbnailData(
192
+ img,
193
+ 300,
194
+ 300,
195
+ file.type
196
+ );
197
+
198
+ // Clean up temporary URL
199
+ URL.revokeObjectURL(tempUrl);
200
+
201
+ resolve({
202
+ success: true,
203
+ imageData,
204
+ thumbnailData,
205
+ metadata,
206
+ });
207
+ } catch (error) {
208
+ URL.revokeObjectURL(tempUrl);
209
+ resolve({
210
+ success: false,
211
+ error: error instanceof Error ? error.message : "Processing failed",
212
+ });
213
+ }
214
+ };
215
+
216
+ img.onerror = () => {
217
+ URL.revokeObjectURL(tempUrl);
218
+ resolve({ success: false, error: "Invalid image file" });
219
+ };
220
+
221
+ img.src = tempUrl;
222
+ });
223
+ } catch (error) {
224
+ console.error("Error processing playlist cover:", error);
225
+ return {
226
+ success: false,
227
+ error: error instanceof Error ? error.message : "Unknown error",
228
+ };
229
+ }
230
+ }
231
+
232
+ // Create thumbnail data as ArrayBuffer from image element
233
+ async function createThumbnailData(
234
+ img: HTMLImageElement,
235
+ maxWidth: number,
236
+ maxHeight: number,
237
+ mimeType: string = "image/jpeg"
238
+ ): Promise<ArrayBuffer> {
239
+ const canvas = document.createElement("canvas");
240
+ const ctx = canvas.getContext("2d");
241
+
242
+ if (!ctx) {
243
+ throw new Error("Cannot create canvas context");
244
+ }
245
+
246
+ // Calculate thumbnail dimensions (maintain aspect ratio)
247
+ let { width, height } = img;
248
+
249
+ if (width > maxWidth || height > maxHeight) {
250
+ const ratio = Math.min(maxWidth / width, maxHeight / height);
251
+ width *= ratio;
252
+ height *= ratio;
253
+ }
254
+
255
+ canvas.width = width;
256
+ canvas.height = height;
257
+
258
+ // Draw image to canvas
259
+ ctx.drawImage(img, 0, 0, width, height);
260
+
261
+ // Convert to ArrayBuffer
262
+ return new Promise((resolve, reject) => {
263
+ canvas.toBlob(
264
+ async (blob) => {
265
+ if (blob) {
266
+ const arrayBuffer = await blob.arrayBuffer();
267
+ resolve(arrayBuffer);
268
+ } else {
269
+ reject(new Error("Failed to create thumbnail data"));
270
+ }
271
+ },
272
+ mimeType,
273
+ 0.8
274
+ );
275
+ });
276
+ }
277
+
278
+ // Validate image file type and size
279
+ export function validateImageFile(file: File): {
280
+ valid: boolean;
281
+ error?: string;
282
+ } {
283
+ const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
284
+ const maxSize = 10 * 1024 * 1024; // 10MB
285
+
286
+ if (!allowedTypes.includes(file.type)) {
287
+ return {
288
+ valid: false,
289
+ error: "Unsupported image format. Use JPEG, PNG, GIF, or WebP.",
290
+ };
291
+ }
292
+
293
+ if (file.size > maxSize) {
294
+ return {
295
+ valid: false,
296
+ error: "Image file too large. Maximum size is 10MB.",
297
+ };
298
+ }
299
+
300
+ return { valid: true };
301
+ }
302
+
303
+ // Clean up object URLs to prevent memory leaks
304
+ export function cleanupImageUrl(url: string): void {
305
+ if (url.startsWith("blob:")) {
306
+ URL.revokeObjectURL(url);
307
+ }
308
+ }
309
+
310
+ // Convert stored image data to blob URL for display
311
+ export function createImageUrlFromData(
312
+ imageData: ArrayBuffer,
313
+ mimeType: string = "image/jpeg"
314
+ ): string {
315
+ const blob = new Blob([imageData], { type: mimeType });
316
+ return URL.createObjectURL(blob);
317
+ }
318
+
319
+ // Create URLs for both thumbnail and full-size images
320
+ export function createImageUrlsFromData(
321
+ thumbnailData: ArrayBuffer,
322
+ fullSizeData: ArrayBuffer,
323
+ mimeType: string = "image/jpeg"
324
+ ): ImageUrlResult {
325
+ const thumbnailBlob = new Blob([thumbnailData], { type: mimeType });
326
+ const fullSizeBlob = new Blob([fullSizeData], { type: mimeType });
327
+
328
+ return {
329
+ thumbnailUrl: URL.createObjectURL(thumbnailBlob),
330
+ fullSizeUrl: URL.createObjectURL(fullSizeBlob),
331
+ };
332
+ }
333
+
334
+ // Helper to determine which image size to use for different contexts
335
+ export function getImageUrlForContext(
336
+ item: Song | Playlist,
337
+ context: "thumbnail" | "background" | "modal" = "thumbnail"
338
+ ): string | null {
339
+ if (!item?.imageType && !item?.imageFilePath) return null;
340
+
341
+ const { thumbnailData, imageData, imageType, imageFilePath } = item;
342
+
343
+ // For backgrounds and modals, prefer full-size, fallback to thumbnail
344
+ if (context === "background" || context === "modal") {
345
+ if (imageData) {
346
+ return createImageUrlFromData(imageData, imageType ?? "image/jpeg");
347
+ } else if (thumbnailData) {
348
+ return createImageUrlFromData(thumbnailData, imageType ?? "image/jpeg");
349
+ }
350
+ }
351
+
352
+ // For thumbnails, prefer thumbnail size, fallback to full-size
353
+ if (thumbnailData) {
354
+ return createImageUrlFromData(thumbnailData, imageType ?? "image/jpeg");
355
+ } else if (imageData) {
356
+ return createImageUrlFromData(imageData, imageType ?? "image/jpeg");
357
+ }
358
+
359
+ // fallback for standalone mode - use file path directly (works for both file:// and http://)
360
+ if (imageFilePath) {
361
+ return imageFilePath;
362
+ }
363
+
364
+ return null;
365
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import "fake-indexeddb/auto";
3
+ import { IDBFactory } from "fake-indexeddb";
4
+
5
+ // each test gets a fresh idb instance to prevent data leaks
6
+ beforeEach(() => {
7
+ globalThis.indexedDB = new IDBFactory();
8
+ });
9
+
10
+ // reset the db cache so setupDB re-opens after the idb reset
11
+ vi.mock("./indexedDBService.js", async (importOriginal) => {
12
+ const mod = await importOriginal<typeof import("./indexedDBService.js")>();
13
+ return mod;
14
+ });
15
+
16
+ import {
17
+ resetDBCache,
18
+ savePlaybackPosition,
19
+ loadAllPlaybackPositions,
20
+ deletePlaybackPosition,
21
+ saveLastPlayed,
22
+ loadLastPlayed,
23
+ saveSetting,
24
+ loadSetting,
25
+ } from "./indexedDBService.js";
26
+
27
+ describe("indexedDBService integration tests", () => {
28
+ beforeEach(() => {
29
+ resetDBCache();
30
+ });
31
+
32
+ describe("playback positions", () => {
33
+ it("persists a position and reads it back", async () => {
34
+ await savePlaybackPosition("song-a", 55.5);
35
+ const positions = await loadAllPlaybackPositions();
36
+ expect(positions.get("song-a")).toBe(55.5);
37
+ });
38
+
39
+ it("overwrites an existing position", async () => {
40
+ await savePlaybackPosition("song-b", 10);
41
+ await savePlaybackPosition("song-b", 20);
42
+ const positions = await loadAllPlaybackPositions();
43
+ expect(positions.get("song-b")).toBe(20);
44
+ });
45
+
46
+ it("deletes a position", async () => {
47
+ await savePlaybackPosition("song-c", 30);
48
+ await deletePlaybackPosition("song-c");
49
+ const positions = await loadAllPlaybackPositions();
50
+ expect(positions.has("song-c")).toBe(false);
51
+ });
52
+
53
+ it("returns an empty map when no positions exist", async () => {
54
+ const positions = await loadAllPlaybackPositions();
55
+ expect(positions.size).toBe(0);
56
+ });
57
+ });
58
+
59
+ describe("last played", () => {
60
+ it("saves and loads the last-played song id", async () => {
61
+ await saveLastPlayed("pl-1", "song-xyz");
62
+ const result = await loadLastPlayed("pl-1");
63
+ expect(result).toBe("song-xyz");
64
+ });
65
+
66
+ it("returns null when nothing has been played", async () => {
67
+ const result = await loadLastPlayed("pl-none");
68
+ expect(result).toBeNull();
69
+ });
70
+
71
+ it("overwrites the previous last-played entry", async () => {
72
+ await saveLastPlayed("pl-1", "song-1");
73
+ await saveLastPlayed("pl-1", "song-2");
74
+ const result = await loadLastPlayed("pl-1");
75
+ expect(result).toBe("song-2");
76
+ });
77
+ });
78
+
79
+ describe("settings", () => {
80
+ it("saves and loads a string setting", async () => {
81
+ await saveSetting("theme", "dark");
82
+ const result = await loadSetting("theme");
83
+ expect(result).toBe("dark");
84
+ });
85
+
86
+ it("saves and loads a numeric setting", async () => {
87
+ await saveSetting("volume", 0.75);
88
+ const result = await loadSetting("volume");
89
+ expect(result).toBe(0.75);
90
+ });
91
+
92
+ it("returns null for an unknown setting", async () => {
93
+ const result = await loadSetting("nonexistent");
94
+ expect(result).toBeNull();
95
+ });
96
+
97
+ it("overwrites an existing setting", async () => {
98
+ await saveSetting("volume", 0.5);
99
+ await saveSetting("volume", 0.9);
100
+ const result = await loadSetting("volume");
101
+ expect(result).toBe(0.9);
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,202 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // mock idb to avoid issues with opening real IndexedDB in test env
4
+ vi.mock("idb", () => ({
5
+ openDB: vi.fn(),
6
+ }));
7
+
8
+ import {
9
+ setupDB,
10
+ resetDBCache,
11
+ loadAllPlaybackPositions,
12
+ savePlaybackPosition,
13
+ deletePlaybackPosition,
14
+ saveLastPlayed,
15
+ loadLastPlayed,
16
+ saveSetting,
17
+ loadSetting,
18
+ mutateAndNotify,
19
+ updatePlaylist,
20
+ updateSong,
21
+ getSongsWithAudioData,
22
+ PLAYLISTS_STORE,
23
+ SONGS_STORE,
24
+ DB_NAME,
25
+ } from "./indexedDBService.js";
26
+
27
+ // mock BroadcastChannel
28
+ global.BroadcastChannel = vi.fn(() => ({
29
+ postMessage: vi.fn(),
30
+ onmessage: null,
31
+ close: vi.fn(),
32
+ })) as any;
33
+
34
+ const mockDB = {
35
+ getAll: vi.fn(),
36
+ transaction: vi.fn(),
37
+ put: vi.fn(),
38
+ get: vi.fn(),
39
+ delete: vi.fn(),
40
+ };
41
+
42
+ const mockStore = {
43
+ put: vi.fn(),
44
+ get: vi.fn(),
45
+ delete: vi.fn(),
46
+ getAll: vi.fn(),
47
+ };
48
+
49
+ const mockTransaction = {
50
+ objectStore: vi.fn(() => mockStore),
51
+ done: Promise.resolve(),
52
+ };
53
+
54
+ describe("indexedDBService", () => {
55
+ let mockOpenDB: any;
56
+
57
+ beforeEach(async () => {
58
+ vi.clearAllMocks();
59
+ resetDBCache();
60
+
61
+ const { openDB } = await import("idb");
62
+ mockOpenDB = vi.mocked(openDB);
63
+ mockDB.transaction.mockReturnValue(mockTransaction);
64
+ mockTransaction.objectStore.mockReturnValue(mockStore);
65
+ mockTransaction.done = Promise.resolve();
66
+ mockOpenDB.mockResolvedValue(mockDB);
67
+ mockDB.getAll.mockResolvedValue([]);
68
+ mockDB.get.mockResolvedValue(undefined);
69
+ mockDB.put.mockResolvedValue(undefined);
70
+ mockDB.delete.mockResolvedValue(undefined);
71
+ mockStore.put.mockResolvedValue(undefined);
72
+ mockStore.get.mockResolvedValue(undefined);
73
+ mockStore.delete.mockResolvedValue(undefined);
74
+ mockStore.getAll.mockResolvedValue([]);
75
+ });
76
+
77
+ afterEach(() => {
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ describe("constants", () => {
82
+ it("exports DB_NAME", () => {
83
+ expect(typeof DB_NAME).toBe("string");
84
+ expect(DB_NAME.length).toBeGreaterThan(0);
85
+ });
86
+
87
+ it("exports PLAYLISTS_STORE and SONGS_STORE as compat constants", () => {
88
+ expect(PLAYLISTS_STORE).toBe("playlists");
89
+ expect(SONGS_STORE).toBe("songs");
90
+ });
91
+ });
92
+
93
+ describe("setupDB", () => {
94
+ it("calls openDB to open the database", async () => {
95
+ await setupDB();
96
+ expect(mockOpenDB).toHaveBeenCalledWith(DB_NAME, 1, expect.any(Object));
97
+ });
98
+
99
+ it("caches the db connection on repeated calls", async () => {
100
+ await setupDB();
101
+ await setupDB();
102
+ // second call should reuse the cached connection, not open again
103
+ expect(mockOpenDB.mock.calls.length).toBe(1);
104
+ });
105
+ });
106
+
107
+ describe("resetDBCache", () => {
108
+ it("forces a fresh db open after reset", async () => {
109
+ await setupDB();
110
+ resetDBCache();
111
+ await setupDB();
112
+ expect(mockOpenDB.mock.calls.length).toBe(2);
113
+ });
114
+ });
115
+
116
+ describe("loadAllPlaybackPositions", () => {
117
+ it("returns an empty map when no positions are stored", async () => {
118
+ mockDB.getAll.mockResolvedValue([]);
119
+ const result = await loadAllPlaybackPositions();
120
+ expect(result).toBeInstanceOf(Map);
121
+ expect(result.size).toBe(0);
122
+ });
123
+
124
+ it("returns a map keyed by songId", async () => {
125
+ mockDB.getAll.mockResolvedValue([
126
+ { songId: "s1", position: 42, updatedAt: Date.now() },
127
+ { songId: "s2", position: 77, updatedAt: Date.now() },
128
+ ]);
129
+ const result = await loadAllPlaybackPositions();
130
+ expect(result.get("s1")).toBe(42);
131
+ expect(result.get("s2")).toBe(77);
132
+ });
133
+ });
134
+
135
+ describe("savePlaybackPosition", () => {
136
+ it("puts a record into the playbackPositions store", async () => {
137
+ await savePlaybackPosition("song-abc", 99.5);
138
+ expect(mockDB.put).toHaveBeenCalledWith(
139
+ "playbackPositions",
140
+ expect.objectContaining({ songId: "song-abc", position: 99.5 })
141
+ );
142
+ });
143
+ });
144
+
145
+ describe("deletePlaybackPosition", () => {
146
+ it("deletes a record from the playbackPositions store", async () => {
147
+ await deletePlaybackPosition("song-abc");
148
+ expect(mockDB.delete).toHaveBeenCalledWith("playbackPositions", "song-abc");
149
+ });
150
+ });
151
+
152
+ describe("saveLastPlayed / loadLastPlayed", () => {
153
+ it("saves and retrieves the last-played song id", async () => {
154
+ mockDB.get.mockResolvedValue({ playlistId: "pl-1", songId: "song-xyz" });
155
+ await saveLastPlayed("pl-1", "song-xyz");
156
+ const result = await loadLastPlayed("pl-1");
157
+ expect(result).toBe("song-xyz");
158
+ });
159
+
160
+ it("returns null when no last-played exists", async () => {
161
+ mockDB.get.mockResolvedValue(undefined);
162
+ const result = await loadLastPlayed("pl-1");
163
+ expect(result).toBeNull();
164
+ });
165
+ });
166
+
167
+ describe("saveSetting / loadSetting", () => {
168
+ it("saves and retrieves a setting value", async () => {
169
+ mockDB.get.mockResolvedValue({ key: "volume", value: 0.8 });
170
+ await saveSetting("volume", 0.8);
171
+ const result = await loadSetting("volume");
172
+ expect(result).toBe(0.8);
173
+ });
174
+
175
+ it("returns null for missing setting", async () => {
176
+ mockDB.get.mockResolvedValue(undefined);
177
+ const result = await loadSetting("volume");
178
+ expect(result).toBeNull();
179
+ });
180
+ });
181
+
182
+ describe("compat stubs (no-ops)", () => {
183
+ it("mutateAndNotify is a no-op that resolves without error", async () => {
184
+ await expect(
185
+ mutateAndNotify({ dbName: DB_NAME, storeName: "playlists", key: "x", updateFn: () => ({} as any) })
186
+ ).resolves.not.toThrow();
187
+ });
188
+
189
+ it("updatePlaylist is a no-op that resolves without error", async () => {
190
+ await expect(updatePlaylist("id", {})).resolves.not.toThrow();
191
+ });
192
+
193
+ it("updateSong is a no-op that resolves without error", async () => {
194
+ await expect(updateSong("id", {})).resolves.not.toThrow();
195
+ });
196
+
197
+ it("getSongsWithAudioData returns empty array", async () => {
198
+ const result = await getSongsWithAudioData(["s1", "s2"]);
199
+ expect(result).toEqual([]);
200
+ });
201
+ });
202
+ });