@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- 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
|
+
});
|