@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,239 @@
|
|
|
1
|
+
// file processing service for audio filez
|
|
2
|
+
// can handle file validation, metadata extraction, and processing!
|
|
3
|
+
|
|
4
|
+
import { extractAlbumArt, processPlaylistCover } from "./imageService.js";
|
|
5
|
+
import type {
|
|
6
|
+
AudioMetadata,
|
|
7
|
+
FileUploadResult,
|
|
8
|
+
Song,
|
|
9
|
+
} from "../types/playlist.js";
|
|
10
|
+
|
|
11
|
+
// check if file is a supported audio format
|
|
12
|
+
export function isAudioFile(file: File): boolean {
|
|
13
|
+
return file.type.startsWith("audio/");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// validate file size (default 100MB limit)
|
|
17
|
+
export function validateFileSize(file: File, maxSizeMB = 100): boolean {
|
|
18
|
+
const maxBytes = maxSizeMB * 1024 * 1024;
|
|
19
|
+
return file.size <= maxBytes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// extract basic metadata from file name
|
|
23
|
+
function extractMetadataFromFilename(filename: string): Partial<AudioMetadata> {
|
|
24
|
+
// Remove file extension
|
|
25
|
+
const nameWithoutExt = filename.replace(/\.[^/.]+$/, "");
|
|
26
|
+
|
|
27
|
+
// Common patterns: "Artist - Title", "Artist - Album - Title", "Title"
|
|
28
|
+
const dashSplit = nameWithoutExt.split(" - ");
|
|
29
|
+
|
|
30
|
+
if (dashSplit.length === 2) {
|
|
31
|
+
return {
|
|
32
|
+
artist: dashSplit[0]?.trim(),
|
|
33
|
+
title: dashSplit[1]?.trim(),
|
|
34
|
+
};
|
|
35
|
+
} else if (dashSplit.length === 3) {
|
|
36
|
+
return {
|
|
37
|
+
artist: dashSplit[0]?.trim(),
|
|
38
|
+
album: dashSplit[1]?.trim(),
|
|
39
|
+
title: dashSplit[2]?.trim(),
|
|
40
|
+
};
|
|
41
|
+
} else {
|
|
42
|
+
return {
|
|
43
|
+
title: nameWithoutExt.trim(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Extract audio duration using Web Audio API
|
|
49
|
+
async function extractDuration(file: File): Promise<number> {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
try {
|
|
52
|
+
const audio = new Audio();
|
|
53
|
+
const url = URL.createObjectURL(file);
|
|
54
|
+
|
|
55
|
+
audio.addEventListener("loadedmetadata", () => {
|
|
56
|
+
URL.revokeObjectURL(url);
|
|
57
|
+
resolve(audio.duration || 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
audio.addEventListener("error", (e) => {
|
|
61
|
+
URL.revokeObjectURL(url);
|
|
62
|
+
console.warn("could not extract duration from audio file:", e);
|
|
63
|
+
resolve(0); // Don't reject, just return 0
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
audio.src = url;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.warn("failed to create blob URL for duration extraction:", error);
|
|
69
|
+
resolve(0); // Return 0 duration if blob URL creation fails
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Extract cover art from file using ID3 tags and create both thumbnail and full-size versions
|
|
75
|
+
async function extractCoverArt(
|
|
76
|
+
file: File
|
|
77
|
+
): Promise<
|
|
78
|
+
| { fullSizeData: ArrayBuffer; thumbnailData: ArrayBuffer; type: string }
|
|
79
|
+
| undefined
|
|
80
|
+
> {
|
|
81
|
+
try {
|
|
82
|
+
const result = await extractAlbumArt(file);
|
|
83
|
+
if (result.success && result.albumArt) {
|
|
84
|
+
// Convert blob URL to File object for processing
|
|
85
|
+
const response = await fetch(result.albumArt);
|
|
86
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
87
|
+
const blob = new Blob([arrayBuffer], { type: "image/jpeg" });
|
|
88
|
+
const imageFile = new File([blob], "albumart.jpg", {
|
|
89
|
+
type: "image/jpeg",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Clean up the blob URL
|
|
93
|
+
URL.revokeObjectURL(result.albumArt);
|
|
94
|
+
|
|
95
|
+
// Process the image to create both full-size and thumbnail versions
|
|
96
|
+
const processResult = await processPlaylistCover(imageFile);
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
processResult.success &&
|
|
100
|
+
processResult.imageData &&
|
|
101
|
+
processResult.thumbnailData
|
|
102
|
+
) {
|
|
103
|
+
return {
|
|
104
|
+
fullSizeData: processResult.imageData,
|
|
105
|
+
thumbnailData: processResult.thumbnailData,
|
|
106
|
+
type: processResult.metadata?.format || "image/jpeg",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Fallback: if processing fails, use original as both
|
|
111
|
+
return {
|
|
112
|
+
fullSizeData: arrayBuffer,
|
|
113
|
+
thumbnailData: arrayBuffer,
|
|
114
|
+
type: "image/jpeg",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.warn(
|
|
120
|
+
`o noz! could not extract album art from ${file.name}:`,
|
|
121
|
+
error
|
|
122
|
+
);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Main metadata extraction function
|
|
128
|
+
export async function extractMetadata(file: File): Promise<AudioMetadata> {
|
|
129
|
+
const filenameMetadata = extractMetadataFromFilename(file.name);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const duration = await extractDuration(file);
|
|
133
|
+
const coverArt = await extractCoverArt(file);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
title: filenameMetadata.title || "unknown title",
|
|
137
|
+
artist: filenameMetadata.artist || "unknown artist",
|
|
138
|
+
album: filenameMetadata.album || "unknown album",
|
|
139
|
+
duration,
|
|
140
|
+
coverArtData: coverArt?.fullSizeData,
|
|
141
|
+
coverArtThumbnailData: coverArt?.thumbnailData,
|
|
142
|
+
coverArtType: coverArt?.type,
|
|
143
|
+
};
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error("Error extracting metadata:", error);
|
|
146
|
+
return {
|
|
147
|
+
title: filenameMetadata.title || "unknown title",
|
|
148
|
+
artist: filenameMetadata.artist || "unknown artist",
|
|
149
|
+
album: filenameMetadata.album || "unknown album",
|
|
150
|
+
duration: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Process single file upload
|
|
156
|
+
export async function processAudioFile(file: File): Promise<FileUploadResult> {
|
|
157
|
+
try {
|
|
158
|
+
// Validate file type
|
|
159
|
+
if (!isAudioFile(file)) {
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
error: `unsupported file type: ${file.type}. Please upload an audio file.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Validate file size
|
|
167
|
+
if (!validateFileSize(file)) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: `file too large: ${Math.round(file.size / 1024 / 1024)}MB. maximum size is 100MB.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Extract metadata
|
|
175
|
+
const metadata = await extractMetadata(file);
|
|
176
|
+
|
|
177
|
+
// Create blob URL for audio playback with error handling
|
|
178
|
+
let blobUrl: string | undefined;
|
|
179
|
+
try {
|
|
180
|
+
blobUrl = URL.createObjectURL(file);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.warn("Failed to create blob URL for file:", file.name, error);
|
|
183
|
+
// Continue without blob URL - it can be created later when needed
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
success: true,
|
|
188
|
+
song: {
|
|
189
|
+
id: "", // Will be set by the database service
|
|
190
|
+
file,
|
|
191
|
+
blobUrl,
|
|
192
|
+
title: metadata.title || "unknown title",
|
|
193
|
+
artist: metadata.artist || "unknown artist",
|
|
194
|
+
album: metadata.album || "unknown album",
|
|
195
|
+
duration: metadata.duration || 0,
|
|
196
|
+
position: 0, // Will be set when adding to playlist
|
|
197
|
+
imageData: metadata.coverArtData,
|
|
198
|
+
thumbnailData: metadata.coverArtThumbnailData,
|
|
199
|
+
imageType: metadata.coverArtType,
|
|
200
|
+
fileSize: file.size,
|
|
201
|
+
createdAt: Date.now(),
|
|
202
|
+
updatedAt: Date.now(),
|
|
203
|
+
playlistId: "", // Will be set when adding to playlist
|
|
204
|
+
} as Song,
|
|
205
|
+
};
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error("onoz! error processing audio file:", error);
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
error: `error processing file: ${error instanceof Error ? error.message : "unknown error"}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Process multiple files
|
|
216
|
+
export async function processAudioFiles(
|
|
217
|
+
files: FileList | File[]
|
|
218
|
+
): Promise<FileUploadResult[]> {
|
|
219
|
+
const fileArray = Array.from(files);
|
|
220
|
+
const results: FileUploadResult[] = [];
|
|
221
|
+
|
|
222
|
+
// Process files in parallel but limit concurrency to avoid overwhelming the browser
|
|
223
|
+
const BATCH_SIZE = 3;
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < fileArray.length; i += BATCH_SIZE) {
|
|
226
|
+
const batch = fileArray.slice(i, i + BATCH_SIZE);
|
|
227
|
+
const batchResults = await Promise.all(
|
|
228
|
+
batch.map((file) => processAudioFile(file))
|
|
229
|
+
);
|
|
230
|
+
results.push(...batchResults);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Filter files to only include audio files
|
|
237
|
+
export function filterAudioFiles(files: FileList | File[]): File[] {
|
|
238
|
+
return Array.from(files).filter(isAudioFile);
|
|
239
|
+
}
|