@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,685 @@
1
+
2
+ import {
3
+ createSignal,
4
+ createEffect,
5
+ createMemo,
6
+ on,
7
+ onMount,
8
+ onCleanup,
9
+ } from "solid-js";
10
+ import type { Playlist, Song } from "../types/playlist.js";
11
+ import { createDocIndexQuery } from "./createDocIndexQuery.js";
12
+ import {
13
+ createPlaylist,
14
+ updatePlaylist,
15
+ deletePlaylist,
16
+ addSongToPlaylist,
17
+ deleteSong,
18
+ reorderSongsInDoc,
19
+ getSongsFromHandle,
20
+ docToPlaylistAsync,
21
+ } from "../services/playlistDocService.js";
22
+ import { findPlaylistDoc } from "../services/automergeRepo.js";
23
+ import { parsePlaylistDoc } from "@freqhole/api-client/playlistz";
24
+ import {
25
+ refreshPlaylistQueue,
26
+ audioState,
27
+ stop,
28
+ } from "../services/audioService.js";
29
+ import { filterAudioFiles } from "../services/fileProcessingService.js";
30
+ import { log } from "../utils/log.js";
31
+ import {
32
+ parsePlaylistZip,
33
+ downloadPlaylistAsZip,
34
+ } from "../services/playlistDownloadService.js";
35
+ import {
36
+ cacheAudioFile,
37
+ initializeOfflineSupport,
38
+ updatePWAManifest,
39
+ } from "../services/offlineService.js";
40
+ import {
41
+ initializeAllStandalonePlaylists,
42
+ clearStandaloneLoadingProgress,
43
+ enrichSongsWithStandalonePaths,
44
+ enrichPlaylistWithStandalonePaths,
45
+ standalonePreferredDocId,
46
+ setStandalonePreferredDocId,
47
+ } from "../services/standaloneService.js";
48
+ import { getImageUrlForContext } from "../services/imageService.js";
49
+ import type { AutomergeUrl } from "@automerge/automerge-repo";
50
+ import type { DocIndexEntry } from "../services/indexedDBService.js";
51
+ import { saveSetting, loadSetting } from "../services/indexedDBService.js";
52
+
53
+ const SETTING_SELECTED_PLAYLIST = "selectedPlaylistId";
54
+
55
+ export function usePlaylistManager() {
56
+ const [playlists, setPlaylists] = createSignal<Playlist[]>([]);
57
+ const [selectedPlaylistId, setSelectedPlaylistId] = createSignal<string | null>(null);
58
+ // derived: always reflects the current version from playlists(), so stale
59
+ // playlist objects from before songs/edits can never overwrite fresh state.
60
+ const selectedPlaylist = createMemo(
61
+ () => playlists().find((p) => p.id === selectedPlaylistId()) ?? null
62
+ );
63
+ // compat wrapper: also upserts the playlist into playlists() if not present
64
+ // (needed for standalone mode which sets selection before the docIndex syncs)
65
+ const setSelectedPlaylist = (p: Playlist | null) => {
66
+ if (p) setPlaylists((prev) => (prev.some((pl) => pl.id === p.id) ? prev : [...prev, p]));
67
+ setSelectedPlaylistId(p?.id ?? null);
68
+ };
69
+ const [playlistSongs, setPlaylistSongs] = createSignal<Song[]>([]);
70
+ const [isInitialized, setIsInitialized] = createSignal(false);
71
+ const [error, setError] = createSignal<string | null>(null);
72
+
73
+ // persist selection to idb whenever it changes so it survives a reload.
74
+ // the effect only runs after the signal has a non-null value so we don't
75
+ // overwrite a saved selection with null during the initial startup sync.
76
+ createEffect(() => {
77
+ const id = selectedPlaylistId();
78
+ if (id) void saveSetting(SETTING_SELECTED_PLAYLIST, id);
79
+ });
80
+
81
+ // modal and UI state
82
+ const [showImageModal, setShowImageModal] = createSignal(false);
83
+ const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
84
+ const [modalImageIndex, setModalImageIndex] = createSignal(0);
85
+
86
+ // loading and operation state
87
+ const [isDownloading, setIsDownloading] = createSignal(false);
88
+ const [isCaching, setIsCaching] = createSignal(false);
89
+ const [allSongsCached, setAllSongsCached] = createSignal(false);
90
+
91
+ const [backgroundImageUrl, setBackgroundImageUrl] = createSignal<
92
+ string | null
93
+ >(null);
94
+ const [imageUrlCache] = createSignal(new Map<string, string>());
95
+
96
+ const [backgroundOverride, setBackgroundOverride] = createSignal<
97
+ Song | "cover" | null
98
+ >(null);
99
+
100
+ const [backgroundSource, setBackgroundSource] = createSignal<string | null>(
101
+ null
102
+ );
103
+
104
+ // live docIndex query - drives sidebar
105
+ const docIndexEntries = createDocIndexQuery();
106
+
107
+ // unsubscribe fn for the selected playlist's doc change listener
108
+ let docStoreCleanup: (() => void) | null = null;
109
+
110
+ // convert docIndex entries to Playlist view objects and update signal
111
+ let _syncCalls = 0;
112
+ async function syncPlaylistsFromDocIndex(
113
+ entries: DocIndexEntry[]
114
+ ): Promise<void> {
115
+ _syncCalls++;
116
+ const syncId = _syncCalls;
117
+ log.debug("playlist.sync", "syncPlaylists #", String(syncId), "entries:", String(entries.length));
118
+ try {
119
+ const resolved = await Promise.all(
120
+ entries.map(async (entry) => {
121
+ try {
122
+ const handle = await findPlaylistDoc(
123
+ entry.docId as AutomergeUrl
124
+ );
125
+ const raw = handle.doc();
126
+ const doc = parsePlaylistDoc(raw ?? {});
127
+ const playlist = await docToPlaylistAsync(entry.docId, doc);
128
+ // overlay docIndex remote-source metadata
129
+ playlist.remoteNodeId = entry.remoteNodeId;
130
+ playlist.remoteName = entry.remoteName;
131
+ playlist.remoteAvatarDataUrl = entry.remoteAvatarDataUrl;
132
+ playlist.isForked = entry.isForked;
133
+ return playlist;
134
+ } catch {
135
+ // doc not yet available - use entry metadata as placeholder
136
+ return {
137
+ id: entry.docId,
138
+ title: entry.title,
139
+ description: undefined,
140
+ createdAt: entry.addedAt,
141
+ updatedAt: entry.addedAt,
142
+ songIds: [],
143
+ remoteNodeId: entry.remoteNodeId,
144
+ remoteName: entry.remoteName,
145
+ remoteAvatarDataUrl: entry.remoteAvatarDataUrl,
146
+ isForked: entry.isForked,
147
+ } as Playlist;
148
+ }
149
+ })
150
+ );
151
+
152
+ log.debug("playlist.sync", "syncPlaylists #", String(syncId), "resolved", String(resolved.length));
153
+ setPlaylists(resolved.map(enrichPlaylistWithStandalonePaths));
154
+
155
+ // update selection id only: selectedPlaylist() will auto-derive from playlists()
156
+ const currentId = selectedPlaylistId();
157
+ if (currentId) {
158
+ const stillExists = resolved.some((p) => p.id === currentId);
159
+ if (!stillExists) {
160
+ setSelectedPlaylistId(resolved.length > 0 ? resolved[0]!.id : null);
161
+ }
162
+ } else {
163
+ // no in-memory selection yet - try to restore from idb, fall back to first
164
+ const savedId = await loadSetting<string>(SETTING_SELECTED_PLAYLIST);
165
+ const target = savedId ? resolved.find((p) => p.id === savedId) : null;
166
+ setSelectedPlaylistId(target ? target.id : resolved.length > 0 ? resolved[0]!.id : null);
167
+ }
168
+ } catch (err) {
169
+ log.error("playlist.sync", "error syncing playlists from doc index:", err);
170
+ }
171
+ }
172
+
173
+ const initialize = async () => {
174
+ try {
175
+ setError(null);
176
+
177
+ // check to init standalone mode
178
+ if (window.STANDALONE_MODE) {
179
+ await initializeOfflineSupport();
180
+ await updatePWAManifest("Playlistz", undefined);
181
+
182
+ const deferredData = window.DEFERRED_PLAYLIST_DATA;
183
+ if (deferredData && deferredData.length > 0) {
184
+ try {
185
+ await initializeAllStandalonePlaylists(deferredData, {
186
+ setSelectedPlaylist,
187
+ setPlaylistSongs,
188
+ setSidebarCollapsed: () => {},
189
+ setError,
190
+ });
191
+ delete window.DEFERRED_PLAYLIST_DATA;
192
+ } catch (err) {
193
+ log.error("playlist.init", "error initializing deferred playlist:", err);
194
+ setError("failed to initialize playlist!");
195
+ }
196
+ }
197
+
198
+ clearStandaloneLoadingProgress();
199
+ }
200
+
201
+ try {
202
+ await initializeOfflineSupport();
203
+ } catch (offlineError) {
204
+ log.warn("playlist.init", "offline support initialization failed:", offlineError);
205
+ }
206
+
207
+ setIsInitialized(true);
208
+ } catch (err) {
209
+ log.error("playlist.init", "error initializing playlist manager:", err);
210
+ setError("failed to initialize playlist");
211
+ }
212
+ };
213
+
214
+ const createNewPlaylist = async (title: string = "new playlist") => {
215
+ try {
216
+ setError(null);
217
+ const playlist = await createPlaylist({ title, description: "" });
218
+ return playlist;
219
+ } catch (err) {
220
+ log.error("playlist.create", "error creating playlist:", err);
221
+ setError("failed to create new playlist!");
222
+ return null;
223
+ }
224
+ };
225
+
226
+ const handleFileDrop = async (files: FileList, targetPlaylistId?: string) => {
227
+ try {
228
+ setError(null);
229
+
230
+ if (files.length === 1 && files[0]?.name.toLowerCase().endsWith(".zip")) {
231
+ const zipFile = files[0];
232
+ const result = await parsePlaylistZip(zipFile);
233
+ return result.playlist;
234
+ }
235
+
236
+ const audioFiles = filterAudioFiles(Array.from(files));
237
+ if (audioFiles.length === 0) {
238
+ setError("no audio filez found!");
239
+ return null;
240
+ }
241
+
242
+ let playlistId = targetPlaylistId;
243
+ if (!playlistId) {
244
+ const newPlaylist = await createNewPlaylist("dropped filez");
245
+ if (!newPlaylist) return null;
246
+ playlistId = newPlaylist.id;
247
+ }
248
+
249
+ for (const audioFile of audioFiles) {
250
+ await addSongToPlaylist(playlistId, audioFile);
251
+ }
252
+ // doc-change events from addSongToPlaylist -> flushDoc drive the refresh
253
+
254
+ return playlistId;
255
+ } catch (err) {
256
+ log.error("playlist.drop", "error handling file drop:", err);
257
+ setError("failed to process dropped files");
258
+ return null;
259
+ }
260
+ };
261
+
262
+ // reactive effect: when docIndex changes, refresh the playlists list
263
+ createEffect(() => {
264
+ const entries = docIndexEntries();
265
+ log.debug("playlist.docindex", "docIndex effect fired, entries:", String(entries.length));
266
+ void syncPlaylistsFromDocIndex(entries);
267
+ });
268
+
269
+ // reactive effect: when a standalone playlist is initialized, select it immediately.
270
+ // this overrides any previously remembered selection so the current zip's
271
+ // playlist is always shown first when opening a standalone file:// page.
272
+ // clears standalonePreferredDocId after applying so subsequent playlists()
273
+ // updates (doc changes, sidebar refreshes) don't keep snapping the selection back.
274
+ createEffect(() => {
275
+ const preferred = standalonePreferredDocId();
276
+ if (!preferred) return;
277
+ if (playlists().some((p) => p.id === preferred)) {
278
+ setSelectedPlaylistId(preferred);
279
+ setStandalonePreferredDocId(null);
280
+ }
281
+ });
282
+
283
+ // reactive effect (keyed by playlist id): subscribe to the selected playlist's
284
+ // doc handle so any mutation (adding songs, edits, remote sync) refreshes
285
+ // the songs list and updates the playlist entry in playlists().
286
+ // keyed on selectedPlaylistId so the effect only re-runs when the id changes.
287
+ createEffect(
288
+ on(
289
+ selectedPlaylistId,
290
+ (playlistId) => {
291
+ log.debug("playlist.select", "selection effect fired:", playlistId ?? "null");
292
+ if (docStoreCleanup) {
293
+ docStoreCleanup();
294
+ docStoreCleanup = null;
295
+ }
296
+
297
+ if (!playlistId) {
298
+ setPlaylistSongs([]);
299
+ return;
300
+ }
301
+
302
+ let disposed = false;
303
+
304
+ let _refreshCount = 0;
305
+ const refresh = async (
306
+ handle: Awaited<ReturnType<typeof findPlaylistDoc>>
307
+ ) => {
308
+ _refreshCount++;
309
+ log.debug("playlist.select", "selected-doc refresh #", String(_refreshCount), playlistId);
310
+ try {
311
+ const raw = handle.doc();
312
+ const doc = parsePlaylistDoc(raw ?? {});
313
+ const updated = await docToPlaylistAsync(playlistId, doc);
314
+
315
+ setPlaylists((prev) =>
316
+ prev.map((p) => {
317
+ if (p.id !== playlistId) return p;
318
+ return enrichPlaylistWithStandalonePaths({
319
+ ...updated,
320
+ remoteNodeId: p.remoteNodeId,
321
+ remoteName: p.remoteName,
322
+ remoteAvatarDataUrl: p.remoteAvatarDataUrl,
323
+ isForked: p.isForked,
324
+ });
325
+ })
326
+ );
327
+ // selectedPlaylist() auto-updates from playlists() via memo - no setSelectedPlaylist needed
328
+
329
+ // use the handle we already have - avoids a redundant repo.find()
330
+ const songs = await getSongsFromHandle(playlistId, handle);
331
+ if (!disposed) {
332
+ setPlaylistSongs(enrichSongsWithStandalonePaths(songs));
333
+ }
334
+ } catch (err) {
335
+ log.error("playlist.select", "error refreshing selected playlist doc:", err);
336
+ }
337
+ };
338
+
339
+ void (async () => {
340
+ try {
341
+ const handle = await findPlaylistDoc(playlistId as AutomergeUrl);
342
+ if (disposed) return;
343
+
344
+ const onChange = () => {
345
+ log.debug("playlist.select", "selected-doc change event -> refresh", playlistId);
346
+ void refresh(handle);
347
+ };
348
+ handle.on("change", onChange);
349
+ docStoreCleanup = () => handle.off("change", onChange);
350
+
351
+ await refresh(handle);
352
+ } catch (err) {
353
+ log.error("playlist.select", "error subscribing to playlist doc:", err);
354
+ if (!disposed) {
355
+ setPlaylistSongs([]);
356
+ }
357
+ }
358
+ })();
359
+
360
+ onCleanup(() => {
361
+ disposed = true;
362
+ });
363
+ }
364
+ )
365
+ );
366
+
367
+ // update background image based on override, currently playing song, or selected playlist
368
+ createEffect(() => {
369
+ const override = backgroundOverride();
370
+ const currentSong = audioState.currentSong();
371
+ const currentPlaylist = audioState.currentPlaylist();
372
+ const selectedPl = selectedPlaylist();
373
+ const cache = imageUrlCache();
374
+
375
+ let newImageUrl: string | null = null;
376
+ let cacheKey: string | null = null;
377
+
378
+ if (override && override !== "cover" && override.imageType) {
379
+ cacheKey = `song-${override.id}`;
380
+ if (cache.has(cacheKey)) {
381
+ newImageUrl = cache.get(cacheKey)!;
382
+ } else {
383
+ newImageUrl = getImageUrlForContext(override, "background");
384
+ if (newImageUrl) {
385
+ cache.set(cacheKey, newImageUrl);
386
+ }
387
+ }
388
+ } else if (override === "cover" && selectedPl?.imageType) {
389
+ cacheKey = `playlist-${selectedPl.id}`;
390
+ if (cache.has(cacheKey)) {
391
+ newImageUrl = cache.get(cacheKey)!;
392
+ } else {
393
+ newImageUrl = getImageUrlForContext(selectedPl, "background");
394
+ if (newImageUrl) {
395
+ cache.set(cacheKey, newImageUrl);
396
+ }
397
+ }
398
+ } else if (currentSong?.imageType) {
399
+ cacheKey = `song-${currentSong.id}`;
400
+ if (cache.has(cacheKey)) {
401
+ newImageUrl = cache.get(cacheKey)!;
402
+ } else {
403
+ newImageUrl = getImageUrlForContext(currentSong, "background");
404
+ if (newImageUrl) {
405
+ cache.set(cacheKey, newImageUrl);
406
+ }
407
+ }
408
+ } else if (currentSong && currentPlaylist?.imageType) {
409
+ cacheKey = `playlist-${currentPlaylist.id}`;
410
+ if (cache.has(cacheKey)) {
411
+ newImageUrl = cache.get(cacheKey)!;
412
+ } else {
413
+ newImageUrl = getImageUrlForContext(currentPlaylist, "background");
414
+ if (newImageUrl) {
415
+ cache.set(cacheKey, newImageUrl);
416
+ }
417
+ }
418
+ } else if (selectedPl?.imageType) {
419
+ cacheKey = `playlist-${selectedPl.id}`;
420
+ if (cache.has(cacheKey)) {
421
+ newImageUrl = cache.get(cacheKey)!;
422
+ } else {
423
+ newImageUrl = getImageUrlForContext(selectedPl, "background");
424
+ if (newImageUrl) {
425
+ cache.set(cacheKey, newImageUrl);
426
+ }
427
+ }
428
+ }
429
+
430
+ const prevUrl = backgroundImageUrl();
431
+ if (prevUrl !== newImageUrl) {
432
+ setBackgroundImageUrl(newImageUrl);
433
+ }
434
+ setBackgroundSource(cacheKey);
435
+ });
436
+
437
+ // update PWA manifest when playlist changes
438
+ createEffect(() => {
439
+ const playlist = selectedPlaylist();
440
+ if (playlist) {
441
+ log.debug("playlist.manifest", "PWA manifest effect fired", playlist.id);
442
+ updatePWAManifest(playlist.title, playlist);
443
+ }
444
+ });
445
+
446
+ const getPlaylistById = (id: string): Playlist | undefined => {
447
+ return playlists().find((p) => p.id === id);
448
+ };
449
+
450
+ const playlistExists = (id: string): boolean => {
451
+ return playlists().some((p) => p.id === id);
452
+ };
453
+
454
+ const getPlaylistCount = (): number => {
455
+ return playlists().length;
456
+ };
457
+
458
+ const searchPlaylists = (query: string): Playlist[] => {
459
+ if (!query.trim()) return playlists();
460
+ const lowercaseQuery = query.toLowerCase();
461
+ return playlists().filter(
462
+ (playlist) =>
463
+ playlist.title.toLowerCase().includes(lowercaseQuery) ||
464
+ (playlist.description || "").toLowerCase().includes(lowercaseQuery)
465
+ );
466
+ };
467
+
468
+ const selectPlaylist = (playlist: Playlist | null) => {
469
+ setSelectedPlaylistId(playlist?.id ?? null);
470
+ };
471
+
472
+ const selectById = (id: string) => {
473
+ setSelectedPlaylistId(id);
474
+ };
475
+
476
+ const handlePlaylistUpdate = async (updates: Partial<Playlist>) => {
477
+ const playlist = selectedPlaylist();
478
+ if (!playlist) return;
479
+
480
+ log.debug("playlist.update", "handlePlaylistUpdate", playlist.id, JSON.stringify(updates));
481
+ try {
482
+ setError(null);
483
+ await updatePlaylist(playlist.id, {
484
+ title: updates.title,
485
+ description: updates.description,
486
+ });
487
+ // reactive query will refresh from docIndex
488
+ } catch (err) {
489
+ log.error("playlist.update", "error updating playlist:", err);
490
+ setError("failed to update playlist!");
491
+ }
492
+ };
493
+
494
+ const handleDeletePlaylist = async () => {
495
+ const playlist = selectedPlaylist();
496
+ if (!playlist) return;
497
+
498
+ try {
499
+ setError(null);
500
+
501
+ const currentSong = audioState.currentSong();
502
+ if (currentSong && currentSong.playlistId === playlist.id) {
503
+ stop();
504
+ }
505
+
506
+ await deletePlaylist(playlist.id);
507
+ setSelectedPlaylist(null);
508
+ setShowDeleteConfirm(false);
509
+ } catch (err) {
510
+ log.error("playlist.delete", "error deleting playlist:", err);
511
+ setError("failed to delete playlist!");
512
+ }
513
+ };
514
+
515
+ const handleDownloadPlaylist = async () => {
516
+ const playlist = selectedPlaylist();
517
+ if (!playlist) return;
518
+
519
+ setIsDownloading(true);
520
+ try {
521
+ setError(null);
522
+ await downloadPlaylistAsZip(playlist, {
523
+ includeImages: true,
524
+ generateM3U: true,
525
+ includeHTML: true,
526
+ });
527
+ } catch (err) {
528
+ log.error("playlist.download", "error downloading playlist:", err);
529
+ setError("failed to download playlist!");
530
+ } finally {
531
+ setIsDownloading(false);
532
+ }
533
+ };
534
+
535
+ const handleRemoveSong = async (songId: string, onClose?: () => void) => {
536
+ const playlist = selectedPlaylist();
537
+ if (!playlist) return;
538
+
539
+ try {
540
+ setError(null);
541
+
542
+ const currentSong = audioState.currentSong();
543
+ if (currentSong && currentSong.id === songId) {
544
+ stop();
545
+ }
546
+
547
+ await deleteSong(playlist.id, songId);
548
+ // doc-change event fires from deleteSong -> flushDoc, driving refresh
549
+
550
+ if (onClose) {
551
+ onClose();
552
+ }
553
+ } catch (err) {
554
+ log.error("playlist.songs", "error removing song from playlist:", err);
555
+ setError("failed to remove song from playlist!");
556
+ }
557
+ };
558
+
559
+ const handleReorderSongs = async (oldIndex: number, newIndex: number) => {
560
+ const playlist = selectedPlaylist();
561
+ if (!playlist) return;
562
+
563
+ try {
564
+ setError(null);
565
+ await reorderSongsInDoc(playlist.id, oldIndex, newIndex);
566
+ // doc-change event fires from reorderSongsInDoc -> flushDoc, driving refresh
567
+
568
+ // refresh audio queue if this playlist is currently playing
569
+ const currentPlaylist = audioState.currentPlaylist();
570
+ if (currentPlaylist && currentPlaylist.id === playlist.id) {
571
+ const updated = selectedPlaylist();
572
+ if (updated) {
573
+ await refreshPlaylistQueue(updated);
574
+ }
575
+ }
576
+ } catch (err) {
577
+ log.error("playlist.songs", "error reordering songz:", err);
578
+ setError("failed to reorder songz");
579
+ }
580
+ };
581
+
582
+ const handleCachePlaylist = async () => {
583
+ const songs = playlistSongs();
584
+ if (songs.length === 0) return;
585
+
586
+ setIsCaching(true);
587
+ try {
588
+ setError(null);
589
+
590
+ for (const song of songs) {
591
+ // songs are now blob-store backed; use blobUrl if available
592
+ const url = song.blobUrl;
593
+ if (url && song.id) {
594
+ try {
595
+ await cacheAudioFile(url, song.title || "unknown song");
596
+ } catch {
597
+ // ignore individual caching failures
598
+ }
599
+ }
600
+ }
601
+
602
+ setAllSongsCached(true);
603
+ } catch (err) {
604
+ log.error("playlist.cache", "error caching playlist:", err);
605
+ setError("failed to cache playlist for offline use!");
606
+ } finally {
607
+ setIsCaching(false);
608
+ }
609
+ };
610
+
611
+ onMount(initialize);
612
+
613
+ onCleanup(() => {
614
+ if (docStoreCleanup) {
615
+ docStoreCleanup();
616
+ docStoreCleanup = null;
617
+ }
618
+
619
+ const cache = imageUrlCache();
620
+ cache.forEach((url) => {
621
+ if (url.startsWith("blob:")) {
622
+ URL.revokeObjectURL(url);
623
+ }
624
+ });
625
+ cache.clear();
626
+ });
627
+
628
+ // auto clear error after some time
629
+ createEffect(() => {
630
+ const errorMsg = error();
631
+ if (errorMsg) {
632
+ const timeoutId = setTimeout(() => {
633
+ setError(null);
634
+ }, 10_000);
635
+
636
+ onCleanup(() => clearTimeout(timeoutId));
637
+ }
638
+ });
639
+
640
+ return {
641
+ playlists,
642
+ selectedPlaylist,
643
+ playlistSongs,
644
+ isInitialized,
645
+ error,
646
+ backgroundImageUrl,
647
+ backgroundSource,
648
+ imageUrlCache,
649
+
650
+ // modal and UI state
651
+ showImageModal,
652
+ showDeleteConfirm,
653
+ modalImageIndex,
654
+ isDownloading,
655
+ isCaching,
656
+ allSongsCached,
657
+
658
+ // setterz
659
+ setSelectedPlaylist,
660
+ setPlaylistSongs,
661
+ setShowImageModal,
662
+ setShowDeleteConfirm,
663
+ setModalImageIndex,
664
+ setBackgroundOverride,
665
+
666
+ // actionz
667
+ initialize,
668
+ createNewPlaylist,
669
+ handleFileDrop,
670
+ selectPlaylist,
671
+ selectById,
672
+ handlePlaylistUpdate,
673
+ handleDeletePlaylist,
674
+ handleDownloadPlaylist,
675
+ handleRemoveSong,
676
+ handleReorderSongs,
677
+ handleCachePlaylist,
678
+
679
+ // utilz
680
+ getPlaylistById,
681
+ playlistExists,
682
+ getPlaylistCount,
683
+ searchPlaylists,
684
+ };
685
+ }