@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,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
|
+
});
|