@freqhole/playlistz 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,588 @@
1
+ // standalone playlist ingestion and caching service.
2
+ // ingests FreqholePlaylist data (passed via the data-playlistz attribute on
3
+ // automerge docs and the blob store. designed for standalone zip export playback.
4
+
5
+ import { createSignal } from "solid-js";
6
+ import { saveSetting, loadSetting } from "./indexedDBService.js";
7
+ import { createPlaylistDoc, findPlaylistDoc } from "./automergeRepo.js";
8
+ import {
9
+ emptyPlaylistDoc,
10
+ upsertSong,
11
+ setMetadata,
12
+ parsePlaylistDoc,
13
+ type SongEntry,
14
+ } from "@freqhole/api-client/playlistz";
15
+ import { getBlobMetadata } from "@freqhole/api-client/storage";
16
+ import { addDocIndexEntry } from "./docIndexService.js";
17
+ import {
18
+ docToPlaylist,
19
+ setSongCoverImage,
20
+ setPlaylistCoverImage,
21
+ getSongsForPlaylist,
22
+ getSongById,
23
+ } from "./playlistDocService.js";
24
+ import { downloadSongIfNeeded } from "./streamingAudioService.js";
25
+ import type { AutomergeUrl } from "@automerge/automerge-repo";
26
+ import type { Playlist, Song } from "../types/playlist.js";
27
+ import type {
28
+ FreqholePlaylist,
29
+ FreqholePlaylistSong,
30
+ } from "../utils/standaloneTemplates.js";
31
+
32
+ // backwards-compatible alias for FreqholePlaylist
33
+ export type StandaloneData = FreqholePlaylist;
34
+
35
+ // interface for callback functions
36
+ interface StandaloneCallbacks {
37
+ setSelectedPlaylist: (playlist: Playlist) => void;
38
+ setPlaylistSongs: (songs: Song[]) => void;
39
+ setSidebarCollapsed: (collapsed: boolean) => void;
40
+ setError: (error: string) => void;
41
+ }
42
+
43
+ // loading progress signal
44
+ const [standaloneLoadingProgress, setStandaloneLoadingProgress] = createSignal<{
45
+ current: number;
46
+ total: number;
47
+ currentSong: string;
48
+ phase: "initializing" | "checking" | "updating" | "complete" | "reloading";
49
+ } | null>(null);
50
+
51
+ export { standaloneLoadingProgress, setStandaloneLoadingProgress };
52
+
53
+ // module-level registry: songId -> standaloneFilePath
54
+ // populated during initializeStandalonePlaylist, used by loadStandaloneSongAudioData
55
+ const standalonePathRegistry = new Map<string, string>();
56
+
57
+ // module-level registry: songId -> { imageFilePath, imageType }
58
+ // populated during initializeStandalonePlaylist, used to re-attach image metadata
59
+ // when the doc-change reactive subscription refreshes songs from the automerge doc.
60
+ const standaloneImageRegistry = new Map<string, { imageFilePath: string; imageType?: string }>();
61
+
62
+ // module-level registry: docId -> { imageFilePath, imageType }
63
+ // populated during initializeStandalonePlaylist; used to re-attach image metadata
64
+ // to playlists rebuilt from the automerge doc (which has no view-layer image fields).
65
+ const standalonePlaylistImageRegistry = new Map<string, { imageFilePath?: string; imageType?: string }>();
66
+
67
+ // reactive signal: the docId of the playlist from the current data-playlistz entry.
68
+ // set after initializeStandalonePlaylist determines the docId so usePlaylistManager
69
+ // can select it over any previously remembered selection.
70
+ const [standalonePreferredDocId, setStandalonePreferredDocId] = createSignal<string | null>(null);
71
+ export { standalonePreferredDocId, setStandalonePreferredDocId };
72
+
73
+ // derive a mime type from a file path or extension string.
74
+ // used as a fallback when imageMimeType is missing from the zip data.
75
+ function mimeFromExtension(extOrPath: string): string | undefined {
76
+ const ext = extOrPath.split(".").pop()?.toLowerCase();
77
+ const map: Record<string, string> = {
78
+ jpg: "image/jpeg",
79
+ jpeg: "image/jpeg",
80
+ png: "image/png",
81
+ gif: "image/gif",
82
+ webp: "image/webp",
83
+ avif: "image/avif",
84
+ };
85
+ return ext ? map[ext] : undefined;
86
+ }
87
+
88
+ // for testing: register a standalone path for a song
89
+ export function registerStandalonePath(songId: string, path: string): void {
90
+ standalonePathRegistry.set(songId, path);
91
+ }
92
+
93
+ // for testing: clear all registered paths between test runs
94
+ export function clearStandaloneRegistry(): void {
95
+ standalonePathRegistry.clear();
96
+ standaloneImageRegistry.clear();
97
+ standalonePlaylistImageRegistry.clear();
98
+ setStandalonePreferredDocId(null);
99
+ }
100
+
101
+ // re-attach standaloneFilePath and imageFilePath to songs fetched from the
102
+ // automerge doc. the doc has no knowledge of these view-layer fields, so any
103
+ // reactive refresh via getSongsFromHandle loses them. call this after every
104
+ // doc-sourced song list to restore them from the registries.
105
+ export function enrichSongsWithStandalonePaths(songs: Song[]): Song[] {
106
+ return songs.map((s) => {
107
+ const imgReg = standaloneImageRegistry.get(s.id);
108
+ return {
109
+ ...s,
110
+ standaloneFilePath: s.standaloneFilePath ?? standalonePathRegistry.get(s.id),
111
+ imageFilePath: s.imageFilePath ?? imgReg?.imageFilePath,
112
+ imageType: s.imageType ?? imgReg?.imageType,
113
+ };
114
+ });
115
+ }
116
+
117
+ // re-attach image metadata to a playlist rebuilt from the automerge doc.
118
+ // the doc has no imageFilePath/imageType fields; they live only in the zip
119
+ // data layer. call this after every doc-sourced playlist to restore them.
120
+ export function enrichPlaylistWithStandalonePaths(playlist: Playlist): Playlist {
121
+ const reg = standalonePlaylistImageRegistry.get(playlist.id);
122
+ if (!reg) return playlist;
123
+ return {
124
+ ...playlist,
125
+ imageFilePath: playlist.imageFilePath ?? reg.imageFilePath,
126
+ imageType: playlist.imageType ?? reg.imageType,
127
+ };
128
+ }
129
+
130
+ // shape stored in settings store for idempotency tracking
131
+ interface StandaloneRecord {
132
+ rev: number;
133
+ docId: string;
134
+ }
135
+
136
+ // resolve the data file path for a standalone song entry
137
+ function resolveStandalonePath(songData: FreqholePlaylistSong): string {
138
+ return songData.filePath ?? `data/${songData.safeFilename ?? songData.originalFilename}`;
139
+ }
140
+
141
+ // create an automerge doc from standalone playlist data.
142
+ // audio bytes are not fetched upfront - sha from embedded data is stored in the doc.
143
+ async function createStandaloneDoc(
144
+ playlistData: StandaloneData
145
+ ): Promise<string> {
146
+ const { docId, handle } = createPlaylistDoc(
147
+ emptyPlaylistDoc({
148
+ title: playlistData.playlist.title,
149
+ description: playlistData.playlist.description ?? "",
150
+ })
151
+ );
152
+
153
+ handle.change((doc) => {
154
+ for (const songData of playlistData.songs) {
155
+ const entry: SongEntry = {
156
+ id: songData.id,
157
+ title: songData.title,
158
+ artist: songData.artist,
159
+ album: songData.album,
160
+ duration: songData.duration,
161
+ mimeType: songData.mimeType ?? "audio/mpeg",
162
+ fileSize: songData.fileSize,
163
+ sha256: songData.sha ?? "",
164
+ images: [],
165
+ urls: [],
166
+ };
167
+ upsertSong(doc, entry);
168
+ }
169
+ });
170
+
171
+ await addDocIndexEntry({
172
+ docId,
173
+ title: playlistData.playlist.title,
174
+ addedAt: Date.now(),
175
+ source: "local",
176
+ });
177
+
178
+ return docId;
179
+ }
180
+
181
+ // update an existing standalone doc with new revision data.
182
+ async function updateStandaloneDoc(
183
+ docId: string,
184
+ playlistData: StandaloneData
185
+ ): Promise<void> {
186
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
187
+ handle.change((doc) => {
188
+ setMetadata(doc, {
189
+ title: playlistData.playlist.title,
190
+ description: playlistData.playlist.description ?? "",
191
+ });
192
+ for (const songData of playlistData.songs) {
193
+ const entry: SongEntry = {
194
+ id: songData.id,
195
+ title: songData.title,
196
+ artist: songData.artist,
197
+ album: songData.album,
198
+ duration: songData.duration,
199
+ mimeType: songData.mimeType ?? "audio/mpeg",
200
+ fileSize: songData.fileSize,
201
+ sha256: songData.sha ?? "",
202
+ images: [],
203
+ urls: [],
204
+ };
205
+ upsertSong(doc, entry);
206
+ }
207
+ });
208
+ }
209
+
210
+ // build Song view objects for the callbacks.
211
+ // populates standaloneFilePath and imageFilePath from embedded standalone metadata
212
+ // and registers paths in standalonePathRegistry for later audio loading.
213
+ function buildStandaloneSongs(
214
+ playlistData: StandaloneData,
215
+ docId: string
216
+ ): Song[] {
217
+ return playlistData.songs.map((songData, i) => {
218
+ const standaloneFilePath = resolveStandalonePath(songData);
219
+ standalonePathRegistry.set(songData.id, standaloneFilePath);
220
+
221
+ const imageFilePath =
222
+ songData.imageFilePath ??
223
+ (songData.imageExtension
224
+ ? `data/${(songData.safeFilename ?? songData.originalFilename).replace(/\.[^.]+$/, "")}-cover${songData.imageExtension}`
225
+ : undefined);
226
+
227
+ if (imageFilePath) {
228
+ const imageType = songData.imageMimeType ?? mimeFromExtension(imageFilePath);
229
+ standaloneImageRegistry.set(songData.id, { imageFilePath, imageType });
230
+ }
231
+
232
+ const song: Song = {
233
+ id: songData.id,
234
+ title: songData.title,
235
+ artist: songData.artist,
236
+ album: songData.album,
237
+ duration: songData.duration,
238
+ mimeType: songData.mimeType ?? "audio/mpeg",
239
+ fileSize: songData.fileSize,
240
+ originalFilename: songData.originalFilename,
241
+ position: i,
242
+ createdAt: 0,
243
+ updatedAt: 0,
244
+ playlistId: docId,
245
+ sha: songData.sha,
246
+ sha256: songData.sha,
247
+ standaloneFilePath,
248
+ needsImageLoad: !!imageFilePath,
249
+ imageFilePath,
250
+ imageType: songData.imageMimeType ?? (imageFilePath ? mimeFromExtension(imageFilePath) : undefined),
251
+ images: [],
252
+ };
253
+
254
+ return song;
255
+ });
256
+ }
257
+
258
+ // pre-register playlist image metadata before async doc operations fire BroadcastChannel.
259
+ // mirrors the path resolution logic in buildStandalonePlaylist.
260
+ function preRegisterPlaylistImage(playlistData: StandaloneData, docId: string): void {
261
+ const imageFilePath =
262
+ playlistData.playlist.imageFilePath ??
263
+ (playlistData.playlist.imageExtension
264
+ ? `data/playlist-cover${playlistData.playlist.imageExtension}`
265
+ : undefined);
266
+ const imageType = playlistData.playlist.imageMimeType ?? (imageFilePath ? mimeFromExtension(imageFilePath) : undefined);
267
+ standalonePlaylistImageRegistry.set(docId, { imageFilePath, imageType });
268
+ }
269
+
270
+ // build the Playlist view object for standalone callbacks.
271
+ function buildStandalonePlaylist(
272
+ playlistData: StandaloneData,
273
+ docId: string
274
+ ): Playlist {
275
+ const imageFilePath =
276
+ playlistData.playlist.imageFilePath ??
277
+ (playlistData.playlist.imageExtension
278
+ ? `data/playlist-cover${playlistData.playlist.imageExtension}`
279
+ : undefined);
280
+
281
+ standalonePlaylistImageRegistry.set(docId, {
282
+ imageFilePath,
283
+ imageType: playlistData.playlist.imageMimeType ?? (imageFilePath ? mimeFromExtension(imageFilePath) : undefined),
284
+ });
285
+
286
+ return {
287
+ id: docId,
288
+ title: playlistData.playlist.title,
289
+ description: playlistData.playlist.description,
290
+ createdAt: Date.now(),
291
+ updatedAt: Date.now(),
292
+ songIds: playlistData.songs.map((s) => s.id),
293
+ rev: playlistData.playlist.rev,
294
+ needsImageLoad: !!imageFilePath,
295
+ imageFilePath,
296
+ imageType: playlistData.playlist.imageMimeType ?? (imageFilePath ? mimeFromExtension(imageFilePath) : undefined),
297
+ bgFilterEnabled: playlistData.playlist.bgFilterEnabled,
298
+ bgFilterBlur: playlistData.playlist.bgFilterBlur,
299
+ bgFilterContrast: playlistData.playlist.bgFilterContrast,
300
+ bgFilterBrightness: playlistData.playlist.bgFilterBrightness,
301
+ coverFilterEnabled: playlistData.playlist.coverFilterEnabled,
302
+ coverFilterBlur: playlistData.playlist.coverFilterBlur,
303
+ };
304
+ }
305
+
306
+ // fetch images from standalone file paths, store in the blob store,
307
+ // update the doc with image refs, and refresh callbacks.
308
+ // skipped for file:// protocol since components read images directly from paths.
309
+ async function loadStandaloneImages(
310
+ _playlistData: StandaloneData,
311
+ docId: string,
312
+ playlist: Playlist,
313
+ songs: Song[],
314
+ callbacks: StandaloneCallbacks
315
+ ): Promise<void> {
316
+ if (window.location.protocol === "file:") {
317
+ return;
318
+ }
319
+
320
+ try {
321
+ let anyChanged = false;
322
+
323
+ // load playlist cover image
324
+ if (playlist.imageFilePath) {
325
+ try {
326
+ const response = await fetch(playlist.imageFilePath);
327
+ if (response.ok) {
328
+ const imageData = await response.arrayBuffer();
329
+ const mimeType = playlist.imageType ?? "image/jpeg";
330
+ await setPlaylistCoverImage(docId, imageData, mimeType);
331
+ anyChanged = true;
332
+ }
333
+ } catch (err) {
334
+ console.warn(
335
+ `could not load playlist cover from ${playlist.imageFilePath}:`,
336
+ err
337
+ );
338
+ }
339
+ }
340
+
341
+ // load song cover images
342
+ for (const song of songs) {
343
+ if (!song.imageFilePath) continue;
344
+ try {
345
+ const response = await fetch(song.imageFilePath);
346
+ if (response.ok) {
347
+ const imageData = await response.arrayBuffer();
348
+ const mimeType = song.imageType ?? "image/jpeg";
349
+ await setSongCoverImage(docId, song.id, imageData, mimeType);
350
+ anyChanged = true;
351
+ }
352
+ } catch (err) {
353
+ console.warn(
354
+ `could not load song image from ${song.imageFilePath}:`,
355
+ err
356
+ );
357
+ }
358
+ }
359
+
360
+ if (anyChanged) {
361
+ // refresh songs from doc (now has image refs) and re-add standalone paths
362
+ const updatedDocSongs = await getSongsForPlaylist(docId);
363
+ const updatedSongs = updatedDocSongs.map((s) => ({
364
+ ...s,
365
+ standaloneFilePath: standalonePathRegistry.get(s.id),
366
+ }));
367
+ callbacks.setPlaylistSongs(updatedSongs);
368
+
369
+ // refresh playlist from doc
370
+ const handle = await findPlaylistDoc(docId as AutomergeUrl);
371
+ const raw = handle.doc();
372
+ const doc = parsePlaylistDoc(raw ?? {});
373
+ callbacks.setSelectedPlaylist(docToPlaylist(docId, doc));
374
+ }
375
+ } catch (err) {
376
+ console.warn("error loading standalone images:", err);
377
+ }
378
+ }
379
+
380
+ // initialize a standalone playlist from embedded data.
381
+ // idempotency: stored in settings as "standalone:<playlistId>" -> { rev, docId }.
382
+ // same rev -> use existing doc; higher rev -> update existing doc; no record -> create.
383
+ export async function initializeStandalonePlaylist(
384
+ playlistData: StandaloneData,
385
+ callbacks: StandaloneCallbacks
386
+ ): Promise<void> {
387
+ try {
388
+ if (!playlistData?.playlist || !playlistData?.songs) {
389
+ console.error(
390
+ "error initializing standalone playlist: invalid playlist data"
391
+ );
392
+ callbacks.setError("invalid playlist data provided");
393
+ return;
394
+ }
395
+
396
+ if (!callbacks.setError || typeof callbacks.setError !== "function") {
397
+ throw new Error("callbacks.setError is not a function");
398
+ }
399
+ if (
400
+ !callbacks.setPlaylistSongs ||
401
+ typeof callbacks.setPlaylistSongs !== "function"
402
+ ) {
403
+ throw new Error("callbacks.setPlaylistSongs is not a function");
404
+ }
405
+
406
+ // pre-populate path/image registries synchronously before any awaits.
407
+ // the BroadcastChannel from addDocIndexEntry fires on the next event loop
408
+ // tick, causing SongRow components to render and call enrichSongsWithStandalonePaths
409
+ // before buildStandaloneSongs runs. pre-registering here ensures image/path
410
+ // data is available when those enrichment calls happen.
411
+ for (const songData of playlistData.songs) {
412
+ standalonePathRegistry.set(songData.id, resolveStandalonePath(songData));
413
+ const imageFilePath =
414
+ songData.imageFilePath ??
415
+ (songData.imageExtension
416
+ ? `data/${(songData.safeFilename ?? songData.originalFilename).replace(/\.[^.]+$/, "")}-cover${songData.imageExtension}`
417
+ : undefined);
418
+ if (imageFilePath) {
419
+ standaloneImageRegistry.set(songData.id, {
420
+ imageFilePath,
421
+ imageType: songData.imageMimeType,
422
+ });
423
+ }
424
+ }
425
+
426
+ setStandaloneLoadingProgress({
427
+ current: 0,
428
+ total: playlistData.songs.length,
429
+ currentSong: "initializing...",
430
+ phase: "initializing",
431
+ });
432
+
433
+ const settingKey = `standalone:${playlistData.playlist.id}`;
434
+ const existing = await loadSetting<StandaloneRecord>(settingKey);
435
+ const incomingRev = playlistData.playlist.rev ?? 0;
436
+
437
+ let docId: string;
438
+
439
+ if (!existing) {
440
+ setStandaloneLoadingProgress({
441
+ current: 0,
442
+ total: playlistData.songs.length,
443
+ currentSong: "creating playlist...",
444
+ phase: "initializing",
445
+ });
446
+ docId = await createStandaloneDoc(playlistData);
447
+ // register immediately after docId is known - BroadcastChannel fires on
448
+ // the next macrotask so this runs before enrichPlaylistWithStandalonePaths
449
+ preRegisterPlaylistImage(playlistData, docId);
450
+ setStandalonePreferredDocId(docId);
451
+ await saveSetting(settingKey, { rev: incomingRev, docId });
452
+ } else if (incomingRev > existing.rev) {
453
+ setStandaloneLoadingProgress({
454
+ current: 0,
455
+ total: playlistData.songs.length,
456
+ currentSong: "updating playlist revision...",
457
+ phase: "reloading",
458
+ });
459
+ docId = existing.docId;
460
+ preRegisterPlaylistImage(playlistData, docId);
461
+ setStandalonePreferredDocId(docId);
462
+ await updateStandaloneDoc(docId, playlistData);
463
+ await saveSetting(settingKey, { rev: incomingRev, docId });
464
+ } else {
465
+ docId = existing.docId;
466
+ preRegisterPlaylistImage(playlistData, docId);
467
+ setStandalonePreferredDocId(docId);
468
+ setStandaloneLoadingProgress({
469
+ current: 0,
470
+ total: playlistData.songs.length,
471
+ currentSong: "loading playlist...",
472
+ phase: "checking",
473
+ });
474
+ }
475
+
476
+ // populate docService song registry for getSongById lookups during audio loading
477
+ await getSongsForPlaylist(docId);
478
+
479
+ // build view objects with standalone-specific fields
480
+ const songs = buildStandaloneSongs(playlistData, docId);
481
+ const playlist = buildStandalonePlaylist(playlistData, docId);
482
+
483
+ callbacks.setSelectedPlaylist(playlist);
484
+ callbacks.setPlaylistSongs(songs);
485
+
486
+ setTimeout(() => setStandaloneLoadingProgress(null), 500);
487
+
488
+ // background: fetch images from file paths and store in blob store
489
+ setTimeout(
490
+ () =>
491
+ loadStandaloneImages(
492
+ playlistData,
493
+ docId,
494
+ playlist,
495
+ songs,
496
+ callbacks
497
+ ),
498
+ 1000
499
+ );
500
+ } catch (err) {
501
+ console.error("error initializing standalone playlist:", err);
502
+ callbacks.setError("failed to load standalone playlist");
503
+ setStandaloneLoadingProgress(null);
504
+ }
505
+ }
506
+
507
+ // load and cache a standalone song's audio bytes into the blob store.
508
+ // delegates to downloadSongIfNeeded from streamingAudioService.
509
+ // returns true if audio is available (already cached, file:// protocol, or downloaded).
510
+ export async function loadStandaloneSongAudioData(
511
+ songId: string
512
+ ): Promise<boolean> {
513
+ try {
514
+ if (window.location.protocol === "file:") {
515
+ return true;
516
+ }
517
+
518
+ const standaloneFilePath = standalonePathRegistry.get(songId);
519
+ if (!standaloneFilePath) {
520
+ console.error(`no registered standalone path for song ${songId}`);
521
+ return false;
522
+ }
523
+
524
+ // use doc registry entry for sha-based dedup check inside downloadSongIfNeeded
525
+ const song = await getSongById(songId);
526
+ const songForDownload: Song = song ?? {
527
+ id: songId,
528
+ title: songId,
529
+ artist: "",
530
+ album: "",
531
+ duration: 0,
532
+ mimeType: "audio/mpeg",
533
+ originalFilename: standaloneFilePath.split("/").pop() ?? songId,
534
+ position: 0,
535
+ createdAt: 0,
536
+ updatedAt: 0,
537
+ playlistId: "",
538
+ };
539
+
540
+ return await downloadSongIfNeeded(songForDownload, standaloneFilePath);
541
+ } catch (error) {
542
+ console.error(
543
+ `error loading standalone song audio data for ${songId}:`,
544
+ error
545
+ );
546
+ return false;
547
+ }
548
+ }
549
+
550
+ // check whether a song's audio needs to be cached.
551
+ // returns false for file:// protocol (audio served directly from disk) and
552
+ // for songs already present in the blob store keyed by sha.
553
+ export async function songNeedsAudioData(song: Song): Promise<boolean> {
554
+ if (window.location.protocol === "file:") {
555
+ return false;
556
+ }
557
+
558
+ const sha = song.sha ?? song.sha256;
559
+ if (!sha) {
560
+ return true;
561
+ }
562
+
563
+ try {
564
+ const existing = await getBlobMetadata(sha);
565
+ return !existing;
566
+ } catch (error) {
567
+ console.error(
568
+ `error checking song audio data status for ${song.id}:`,
569
+ error
570
+ );
571
+ return true;
572
+ }
573
+ }
574
+
575
+ // clear loading progress (for cleanup or programmatic use)
576
+ export function clearStandaloneLoadingProgress(): void {
577
+ setStandaloneLoadingProgress(null);
578
+ }
579
+
580
+ // initialize multiple standalone playlists in sequence
581
+ export async function initializeAllStandalonePlaylists(
582
+ playlists: FreqholePlaylist[],
583
+ callbacks: StandaloneCallbacks
584
+ ): Promise<void> {
585
+ for (const playlistData of playlists) {
586
+ await initializeStandalonePlaylist(playlistData, callbacks);
587
+ }
588
+ }