@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,400 @@
1
+
2
+ import { createSignal, createEffect, onMount, onCleanup } from "solid-js";
3
+ import type { Playlist } from "../types/playlist.js";
4
+ import { log } from "../utils/log.js";
5
+ import {
6
+ filterAudioFiles,
7
+ extractMetadata,
8
+ } from "../services/fileProcessingService.js";
9
+ import { parsePlaylistZip } from "../services/playlistDownloadService.js";
10
+ import {
11
+ createPlaylist,
12
+ addSongToPlaylist,
13
+ } from "../services/playlistDocService.js";
14
+
15
+ export interface DragInfo {
16
+ type: "audio-files" | "non-audio-files" | "song-reorder" | "unknown";
17
+ itemCount: number;
18
+ }
19
+
20
+ export function useDragAndDrop() {
21
+ // drag state
22
+ const [isDragOver, setIsDragOver] = createSignal(false);
23
+ const [dragInfo, setDragInfo] = createSignal<DragInfo>({
24
+ type: "unknown",
25
+ itemCount: 0,
26
+ });
27
+ const [error, setError] = createSignal<string | null>(null);
28
+
29
+ // what's being dragged?
30
+ const analyzeDragData = (e: DragEvent): DragInfo => {
31
+ // check types array first since getData() is restricted during dragenter
32
+ const types = e.dataTransfer?.types;
33
+
34
+ // song reorder: SongRow sets text/plain data, so types will include "text/plain" but not "Files"
35
+ if (types && types.includes("text/plain") && !types.includes("Files")) {
36
+ return { type: "song-reorder", itemCount: 1 };
37
+ }
38
+
39
+ // file drops: will have "Files" in types array
40
+ if (types && types.includes("Files")) {
41
+ return { type: "audio-files", itemCount: 1 };
42
+ }
43
+
44
+ // fallback: try to read data directly (works during drop events)
45
+ const textData = e.dataTransfer?.getData("text/plain");
46
+ if (textData && !isNaN(parseInt(textData, 10))) {
47
+ return { type: "song-reorder", itemCount: 1 };
48
+ }
49
+
50
+ // legacy support for json data
51
+ const dragData = e.dataTransfer?.getData("application/json");
52
+ if (dragData) {
53
+ try {
54
+ const data = JSON.parse(dragData);
55
+ if (data.type === "song-reorder") {
56
+ return { type: "song-reorder", itemCount: 1 };
57
+ }
58
+ } catch {
59
+ // not json, continue with file analysis...
60
+ }
61
+ }
62
+
63
+ // check items array as additional fallback
64
+ const items = e.dataTransfer?.items;
65
+
66
+ if (items && items.length > 0) {
67
+ const hasFiles = Array.from(items).some((item) => item.kind === "file");
68
+ if (hasFiles) {
69
+ return { type: "audio-files", itemCount: items.length };
70
+ }
71
+ }
72
+
73
+ // fallback to checking files (available during drop event)
74
+ const files = e.dataTransfer?.files;
75
+ if (files && files.length > 0) {
76
+ const zipFiles = Array.from(files).filter(
77
+ (file) =>
78
+ file.type === "application/zip" ||
79
+ file.name.toLowerCase().endsWith(".zip")
80
+ );
81
+
82
+ if (zipFiles.length > 0) {
83
+ return { type: "audio-files", itemCount: zipFiles.length };
84
+ }
85
+
86
+ const audioFiles = filterAudioFiles(files);
87
+ if (audioFiles.length > 0) {
88
+ return { type: "audio-files", itemCount: audioFiles.length };
89
+ }
90
+
91
+ return { type: "non-audio-files", itemCount: files.length };
92
+ }
93
+
94
+ return { type: "unknown", itemCount: 0 };
95
+ };
96
+
97
+ const handleDragEnter = (e: DragEvent) => {
98
+ e.preventDefault();
99
+ e.stopPropagation();
100
+
101
+ const info = analyzeDragData(e);
102
+ setDragInfo(info);
103
+
104
+ // only show drag overlay for file drops, not song reordering
105
+ if (info.type !== "song-reorder") {
106
+ setIsDragOver(true);
107
+ }
108
+ };
109
+
110
+ const handleDragOver = (e: DragEvent) => {
111
+ e.preventDefault();
112
+ e.stopPropagation();
113
+
114
+ // update drag effect based on content
115
+ const info = dragInfo();
116
+ if (info.type === "audio-files" || info.type === "song-reorder") {
117
+ e.dataTransfer!.dropEffect = "copy";
118
+ } else {
119
+ e.dataTransfer!.dropEffect = "none";
120
+ }
121
+ };
122
+
123
+ const handleDragLeave = (e: DragEvent) => {
124
+ e.preventDefault();
125
+ e.stopPropagation();
126
+
127
+ // only handle drag leave for file drops, not song reordering
128
+ const info = dragInfo();
129
+ if (info.type === "song-reorder") {
130
+ return;
131
+ }
132
+
133
+ // only set drag over to false if leaving the main container
134
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
135
+ const x = e.clientX;
136
+ const y = e.clientY;
137
+
138
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
139
+ setIsDragOver(false);
140
+ setDragInfo({ type: "unknown", itemCount: 0 });
141
+ }
142
+ };
143
+
144
+ const handleDrop = async (
145
+ e: DragEvent,
146
+ options: {
147
+ selectedPlaylist?: Playlist | null;
148
+ playlists: Playlist[];
149
+ onPlaylistCreated?: (playlist: Playlist) => void;
150
+ onPlaylistSelected?: (playlist: Playlist) => void;
151
+ }
152
+ ) => {
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ setIsDragOver(false);
156
+
157
+ const info = dragInfo();
158
+ setDragInfo({ type: "unknown", itemCount: 0 });
159
+
160
+ // only handle file drops here; ignore song reordering
161
+ if (info.type === "song-reorder") {
162
+ return;
163
+ }
164
+
165
+ const files = e.dataTransfer?.files;
166
+ if (!files) return;
167
+
168
+ try {
169
+ setError(null);
170
+
171
+ // check ZIP first
172
+ const zipFiles = Array.from(files).filter(
173
+ (file) =>
174
+ file.type === "application/zip" ||
175
+ file.name.toLowerCase().endsWith(".zip")
176
+ );
177
+
178
+ if (zipFiles.length > 0) {
179
+ await handleZipFiles(zipFiles, options);
180
+ return;
181
+ }
182
+
183
+ const audioFiles = filterAudioFiles(files);
184
+ if (audioFiles.length === 0) {
185
+ handleNonAudioFiles(info);
186
+ return;
187
+ }
188
+
189
+ await handleAudioFiles(audioFiles, options);
190
+ } catch (err) {
191
+ log.error("drag.drop", "error handling file drop:", err);
192
+ setError("Failed to process dropped files");
193
+ setTimeout(() => setError(null), 5000);
194
+ throw err;
195
+ }
196
+ };
197
+
198
+ // processFileImport: the core import logic, callable without a DragEvent.
199
+ // useful for tests and for wiring to file-input onChange handlers.
200
+ const processFileImport = async (
201
+ files: File[],
202
+ options: {
203
+ selectedPlaylist?: Playlist | null;
204
+ playlists: Playlist[];
205
+ onPlaylistCreated?: (playlist: Playlist) => void;
206
+ onPlaylistSelected?: (playlist: Playlist) => void;
207
+ }
208
+ ) => {
209
+ const zipFiles = files.filter(
210
+ (f) =>
211
+ f.type === "application/zip" || f.name.toLowerCase().endsWith(".zip")
212
+ );
213
+ if (zipFiles.length > 0) {
214
+ await handleZipFiles(zipFiles, options);
215
+ return;
216
+ }
217
+ const audioFiles = filterAudioFiles(files as unknown as FileList);
218
+ if (audioFiles.length > 0) {
219
+ await handleAudioFiles(audioFiles, options);
220
+ }
221
+ };
222
+
223
+ const handleZipFiles = async (
224
+ zipFiles: File[],
225
+ options: {
226
+ playlists: Playlist[];
227
+ onPlaylistCreated?: (playlist: Playlist) => void;
228
+ onPlaylistSelected?: (playlist: Playlist) => void;
229
+ }
230
+ ) => {
231
+ for (const zipFile of zipFiles) {
232
+ const { playlist: playlistData, songs: songsData } =
233
+ await parsePlaylistZip(zipFile);
234
+
235
+ log.debug("handleZipFiles", `parsed zip: title="${playlistData.title}" songs=${songsData.length} existing playlists=${options.playlists.length}`);
236
+
237
+ // check if a playlist with the same name and songs already exists
238
+ const existingPlaylist = options.playlists.find(
239
+ (p) =>
240
+ p.title === playlistData.title &&
241
+ p.songIds.length === songsData.length
242
+ );
243
+
244
+ if (existingPlaylist) {
245
+ log.debug("handleZipFiles", `dedup match: "${playlistData.title}" already exists`);
246
+ setError(`Playlist "${playlistData.title}" already exists`);
247
+ setTimeout(() => setError(null), 3000);
248
+ continue;
249
+ }
250
+
251
+ const newPlaylist = await createPlaylist({
252
+ title: playlistData.title,
253
+ description: playlistData.description,
254
+ });
255
+
256
+ log.debug("handleZipFiles", `created playlist ${newPlaylist.id}, adding ${songsData.length} songs`);
257
+
258
+ // and add the songz
259
+ for (const songData of songsData) {
260
+ log.debug("handleZipFiles", `adding song "${songData.title}" audioData=${songData.audioData?.byteLength ?? "none"}`);
261
+ const audioBlob = new Blob([songData.audioData!], {
262
+ type: songData.mimeType,
263
+ });
264
+ const audioFile = new File(
265
+ [audioBlob],
266
+ songData.originalFilename || `${songData.artist} - ${songData.title}`,
267
+ { type: songData.mimeType }
268
+ );
269
+
270
+ await addSongToPlaylist(newPlaylist.id, audioFile, {
271
+ title: songData.title,
272
+ artist: songData.artist,
273
+ album: songData.album,
274
+ duration: songData.duration,
275
+ imageData: songData.imageData,
276
+ imageType: songData.imageType,
277
+ });
278
+ }
279
+
280
+ // callback about playlist creation and selection
281
+ options.onPlaylistCreated?.(newPlaylist);
282
+ options.onPlaylistSelected?.(newPlaylist);
283
+ }
284
+ };
285
+
286
+ const handleAudioFiles = async (
287
+ audioFiles: File[],
288
+ options: {
289
+ selectedPlaylist?: Playlist | null;
290
+ onPlaylistCreated?: (playlist: Playlist) => void;
291
+ onPlaylistSelected?: (playlist: Playlist) => void;
292
+ }
293
+ ) => {
294
+ let targetPlaylist = options.selectedPlaylist;
295
+
296
+ // if no playlist is selected, create a new one
297
+ if (!targetPlaylist) {
298
+ targetPlaylist = await createPlaylist({
299
+ title: "new playlist",
300
+ description: `created from ${audioFiles.length} dropped file${
301
+ audioFiles.length > 1 ? "z" : ""
302
+ }`,
303
+ });
304
+ options.onPlaylistCreated?.(targetPlaylist);
305
+ options.onPlaylistSelected?.(targetPlaylist);
306
+ }
307
+
308
+ // and add the songz to the playlist
309
+ for (const songFile of audioFiles) {
310
+ const metadata = await extractMetadata(songFile);
311
+ await addSongToPlaylist(targetPlaylist.id, songFile, metadata);
312
+ }
313
+ };
314
+
315
+ // contextual error messagez
316
+ const handleNonAudioFiles = (info: DragInfo) => {
317
+ if (info.type === "non-audio-files") {
318
+ setError(
319
+ "only audio filez and ZIP playlist filez can be added. supported formatz: MP3, WAV, M4A, FLAC, OGG, ZIP"
320
+ );
321
+ } else {
322
+ setError(
323
+ "no audio filez or ZIP playlist filez found in the dropped itemz!"
324
+ );
325
+ }
326
+ setTimeout(() => setError(null), 3000);
327
+ };
328
+
329
+ // set 'em up the global drag and drop event listenerz
330
+ onMount(() => {
331
+ const preventDefaults = (e: DragEvent) => {
332
+ e.preventDefault();
333
+ e.stopPropagation();
334
+ };
335
+
336
+ // prevent default drag behaviors on document
337
+ document.addEventListener("dragenter", preventDefaults);
338
+ document.addEventListener("dragover", preventDefaults);
339
+ document.addEventListener("dragleave", preventDefaults);
340
+ document.addEventListener("drop", preventDefaults);
341
+
342
+ onCleanup(() => {
343
+ document.removeEventListener("dragenter", preventDefaults);
344
+ document.removeEventListener("dragover", preventDefaults);
345
+ document.removeEventListener("dragleave", preventDefaults);
346
+ document.removeEventListener("drop", preventDefaults);
347
+ });
348
+ });
349
+
350
+ // clear error after some time
351
+ createEffect(() => {
352
+ const errorMsg = error();
353
+ if (errorMsg) {
354
+ const timeoutId = setTimeout(() => {
355
+ setError(null);
356
+ }, 10_000);
357
+
358
+ onCleanup(() => clearTimeout(timeoutId));
359
+ }
360
+ });
361
+
362
+ // handle file drop wrapper (moved from components/index.tsx)
363
+ const handleFileDrop = async (
364
+ e: DragEvent,
365
+ options: {
366
+ selectedPlaylist?: Playlist | null;
367
+ playlists: Playlist[];
368
+ onPlaylistCreated?: (playlist: Playlist) => void;
369
+ onPlaylistSelected?: (playlist: Playlist) => void;
370
+ }
371
+ ) => {
372
+ try {
373
+ await handleDrop(e, options);
374
+ } catch (error) {
375
+ log.error("drag.drop", "error in handleFileDrop:", error);
376
+ // ensure drag overlay is cleared, even on error
377
+ setIsDragOver(false);
378
+ }
379
+ };
380
+
381
+ return {
382
+ isDragOver,
383
+ dragInfo,
384
+ error,
385
+
386
+ // setterz
387
+ setIsDragOver,
388
+
389
+ // actionz
390
+ handleDragEnter,
391
+ handleDragOver,
392
+ handleDragLeave,
393
+ handleDrop,
394
+ handleFileDrop,
395
+ processFileImport,
396
+
397
+ // utilz
398
+ analyzeDragData,
399
+ };
400
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import type { Playlist, Song } from "../types/playlist.js";
3
+
4
+ // mock getImageUrlForContext so tests don't need real blob URLs
5
+ vi.mock("../services/imageService.js", () => ({
6
+ getImageUrlForContext: vi.fn((item: Playlist | Song) => {
7
+ if ("imageFilePath" in item && item.imageFilePath) return item.imageFilePath;
8
+ if ("imageData" in item && item.imageData) return "blob:mock-url";
9
+ if ("thumbnailData" in item && item.thumbnailData) return "blob:mock-thumb";
10
+ return null;
11
+ }),
12
+ }));
13
+
14
+ import { useImageModal } from "./useImageModal.js";
15
+
16
+ const makePlaylist = (overrides: Partial<Playlist> = {}): Playlist => ({
17
+ id: "pl-1",
18
+ title: "test playlist",
19
+ songIds: [],
20
+ createdAt: 0,
21
+ updatedAt: 0,
22
+ ...overrides,
23
+ });
24
+
25
+ const makeSong = (overrides: Partial<Song> = {}): Song => ({
26
+ id: "song-1",
27
+ title: "test song",
28
+ artist: "artist",
29
+ album: "album",
30
+ duration: 180,
31
+ position: 0,
32
+ mimeType: "audio/mpeg",
33
+ originalFilename: "test.mp3",
34
+ playlistId: "pl-1",
35
+ createdAt: 0,
36
+ updatedAt: 0,
37
+ ...overrides,
38
+ });
39
+
40
+ describe("useImageModal", () => {
41
+ let modal: ReturnType<typeof useImageModal>;
42
+
43
+ beforeEach(() => {
44
+ modal = useImageModal();
45
+ });
46
+
47
+ describe("generateImageList / openImageModal (standalone mode - imageFilePath only)", () => {
48
+ it("includes playlist cover when only imageFilePath is set (no buffer data)", () => {
49
+ const playlist = makePlaylist({ imageFilePath: "data/playlist-cover.jpg", imageType: "image/jpeg" });
50
+ modal.openImageModal(playlist, []);
51
+ expect(modal.showImageModal()).toBe(true);
52
+ expect(modal.getImageCount()).toBe(1);
53
+ expect(modal.getCurrentImageMetadata()?.type).toBe("playlist");
54
+ expect(modal.getCurrentImageUrl()).toBe("data/playlist-cover.jpg");
55
+ });
56
+
57
+ it("includes playlist cover when imageFilePath present but imageType absent", () => {
58
+ const playlist = makePlaylist({ imageFilePath: "data/playlist-cover.jpg" });
59
+ modal.openImageModal(playlist, []);
60
+ expect(modal.showImageModal()).toBe(true);
61
+ expect(modal.getImageCount()).toBe(1);
62
+ });
63
+
64
+ it("includes song images when only imageFilePath is set (no buffer data)", () => {
65
+ const playlist = makePlaylist();
66
+ const song = makeSong({ imageFilePath: "data/01-track-cover.jpg", imageType: "image/jpeg" });
67
+ modal.openImageModal(playlist, [song]);
68
+ expect(modal.getImageCount()).toBe(1);
69
+ expect(modal.getCurrentImageMetadata()?.type).toBe("song");
70
+ expect(modal.getCurrentImageUrl()).toBe("data/01-track-cover.jpg");
71
+ });
72
+
73
+ it("includes songs with imageFilePath but no imageType", () => {
74
+ const playlist = makePlaylist();
75
+ const song = makeSong({ imageFilePath: "data/cover.png" });
76
+ modal.openImageModal(playlist, [song]);
77
+ expect(modal.getImageCount()).toBe(1);
78
+ });
79
+
80
+ it("collects all songs with imageFilePath into the carousel", () => {
81
+ const playlist = makePlaylist({ imageFilePath: "data/playlist-cover.jpg" });
82
+ const songs = [
83
+ makeSong({ id: "s1", imageFilePath: "data/s1-cover.jpg" }),
84
+ makeSong({ id: "s2", imageFilePath: "data/s2-cover.jpg" }),
85
+ makeSong({ id: "s3" }), // no image - should be excluded
86
+ ];
87
+ modal.openImageModal(playlist, songs);
88
+ // playlist cover + 2 songs with images
89
+ expect(modal.getImageCount()).toBe(3);
90
+ });
91
+ });
92
+
93
+ describe("generateImageList / openImageModal (in-memory buffer mode)", () => {
94
+ it("includes playlist cover when imageData buffer is present", () => {
95
+ const playlist = makePlaylist({
96
+ imageType: "image/jpeg",
97
+ imageData: new ArrayBuffer(8),
98
+ });
99
+ modal.openImageModal(playlist, []);
100
+ expect(modal.getImageCount()).toBe(1);
101
+ expect(modal.getCurrentImageMetadata()?.type).toBe("playlist");
102
+ });
103
+
104
+ it("includes song when imageData buffer is present", () => {
105
+ const playlist = makePlaylist();
106
+ const song = makeSong({ imageType: "image/jpeg", imageData: new ArrayBuffer(8) });
107
+ modal.openImageModal(playlist, [song]);
108
+ expect(modal.getImageCount()).toBe(1);
109
+ expect(modal.getCurrentImageMetadata()?.type).toBe("song");
110
+ });
111
+
112
+ it("excludes song with no image data and no imageFilePath", () => {
113
+ const playlist = makePlaylist();
114
+ const song = makeSong(); // no image fields
115
+ modal.openImageModal(playlist, [song]);
116
+ expect(modal.showImageModal()).toBe(false);
117
+ expect(modal.getImageCount()).toBe(0);
118
+ });
119
+ });
120
+
121
+ describe("openImageModal does not open when no images exist", () => {
122
+ it("does not open when playlist has no image and songs have no images", () => {
123
+ const playlist = makePlaylist();
124
+ modal.openImageModal(playlist, [makeSong()]);
125
+ expect(modal.showImageModal()).toBe(false);
126
+ });
127
+
128
+ it("does not open with null playlist and no songs", () => {
129
+ modal.openImageModal(null, []);
130
+ expect(modal.showImageModal()).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe("navigation", () => {
135
+ beforeEach(() => {
136
+ const playlist = makePlaylist({ imageFilePath: "data/playlist-cover.jpg" });
137
+ const songs = [
138
+ makeSong({ id: "s1", imageFilePath: "data/s1-cover.jpg" }),
139
+ makeSong({ id: "s2", imageFilePath: "data/s2-cover.jpg" }),
140
+ ];
141
+ modal.openImageModal(playlist, songs);
142
+ });
143
+
144
+ it("starts at requested index", () => {
145
+ const playlist = makePlaylist({ imageFilePath: "data/playlist-cover.jpg" });
146
+ modal.openImageModal(playlist, []);
147
+ expect(modal.getCurrentImageMetadata()?.type).toBe("playlist");
148
+ });
149
+
150
+ it("handleNextImage advances index", () => {
151
+ expect(modal.getCurrentImageMetadata()?.id).toBe("pl-1");
152
+ modal.handleNextImage();
153
+ expect(modal.getCurrentImageMetadata()?.id).toBe("s1");
154
+ });
155
+
156
+ it("handleNextImage wraps around to first", () => {
157
+ modal.handleNextImage();
158
+ modal.handleNextImage();
159
+ modal.handleNextImage(); // wraps
160
+ expect(modal.getCurrentImageMetadata()?.id).toBe("pl-1");
161
+ });
162
+
163
+ it("handlePrevImage wraps to last image", () => {
164
+ modal.handlePrevImage();
165
+ expect(modal.getCurrentImageMetadata()?.id).toBe("s2");
166
+ });
167
+
168
+ it("closeImageModal clears state", () => {
169
+ modal.closeImageModal();
170
+ expect(modal.showImageModal()).toBe(false);
171
+ expect(modal.getImageCount()).toBe(0);
172
+ });
173
+ });
174
+ });