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