@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,436 @@
1
+ // indexeddb service - phase 4+ schema.
2
+ // musicPlaylistDB v1 contains only non-doc state:
3
+ // playbackPositions, lastPlayed, settings, docIndex, knocks, accessGrants.
4
+ // playlist and song data live in automerge docs (freqhole-automerge idb via
5
+ // IndexedDBStorageAdapter). see playlistDocService for doc-backed crud.
6
+
7
+ import { openDB, type DBSchema, type IDBPDatabase } from "idb";
8
+
9
+ // simple signal implementation for live queries
10
+ interface Signal<T> {
11
+ get: () => T;
12
+ set: (value: T) => void;
13
+ subscribe: (fn: (value: T) => void) => () => void;
14
+ }
15
+
16
+ function createSignal<T>(initial: T): Signal<T> {
17
+ let value = initial;
18
+ const subs = new Set<(value: T) => void>();
19
+
20
+ return {
21
+ get: () => value,
22
+ set: (newVal) => {
23
+ if (value !== newVal) {
24
+ value = newVal;
25
+ subs.forEach((fn) => fn(value));
26
+ }
27
+ },
28
+ subscribe: (fn) => {
29
+ subs.add(fn);
30
+ fn(value);
31
+ return () => subs.delete(fn);
32
+ },
33
+ };
34
+ }
35
+
36
+ // database configuration
37
+ export const DB_NAME = "musicPlaylistDB";
38
+ export const DB_VERSION = 1;
39
+
40
+ // legacy store name constants kept for compatibility with standalone/streaming services.
41
+ // these stores are no longer created in musicPlaylistDB - data lives in automerge docs.
42
+ export const PLAYLISTS_STORE = "playlists";
43
+ export const SONGS_STORE = "songs";
44
+
45
+ export const PLAYBACK_POSITIONS_STORE = "playbackPositions";
46
+ export const LAST_PLAYED_STORE = "lastPlayed";
47
+ export const SETTINGS_STORE = "settings";
48
+ export const DOC_INDEX_STORE = "docIndex";
49
+ export const KNOCKS_STORE = "knocks";
50
+ export const ACCESS_GRANTS_STORE = "accessGrants";
51
+
52
+ // record shape stored per-song in the playbackPositions store
53
+ export interface PlaybackPositionRecord {
54
+ songId: string;
55
+ position: number;
56
+ updatedAt: number;
57
+ }
58
+
59
+ export interface LastPlayedRecord {
60
+ playlistId: string;
61
+ songId: string;
62
+ updatedAt: number;
63
+ }
64
+
65
+ // generic key-value record for ui/app settings
66
+ export interface SettingRecord {
67
+ key: string;
68
+ value: unknown;
69
+ updatedAt: number;
70
+ }
71
+
72
+ // automerge doc index entry: maps an AutomergeUrl to display metadata.
73
+ // used to list known playlists in the sidebar without loading every doc.
74
+ export interface DocIndexEntry {
75
+ docId: string; // AutomergeUrl, e.g. "automerge:abc123..."
76
+ title: string;
77
+ addedAt: number; // unix ms timestamp
78
+ source: "local" | "shared" | "freqhole";
79
+ // set when this playlist was received from a remote peer.
80
+ // absent for locally-created playlists.
81
+ remoteNodeId?: string;
82
+ remoteName?: string;
83
+ remoteAvatarDataUrl?: string;
84
+ // true once the user has forked to a local editable copy
85
+ isForked?: boolean;
86
+ }
87
+
88
+ // inbound or outbound knock request record for the knock inbox/outbox ui.
89
+ export interface KnockRecord {
90
+ id: string; // uuid
91
+ nodeId: string; // requester (inbound) or responder (outbound) iroh node id
92
+ direction: "inbound" | "outbound";
93
+ name: string;
94
+ message: string;
95
+ status: "pending" | "accepted" | "rejected";
96
+ createdAt: number;
97
+ processedAt?: number;
98
+ knockType?: "browse" | "doc_access"; // browse = list playlists; doc_access = specific playlist
99
+ requestedDocId?: string; // set when knockType is "doc_access"
100
+ }
101
+
102
+ // access grant written when an inbound knock is accepted.
103
+ export interface AccessGrantRecord {
104
+ nodeId: string; // the granted peer's iroh node id (keyPath)
105
+ name: string;
106
+ grantedAt: number;
107
+ docIds?: string[]; // docs this grant covers; undefined = all listed playlists
108
+ avatarDataUrl?: string; // their avatar at time of last contact
109
+ }
110
+
111
+ // database schema definition - v1 contains only non-doc state
112
+ interface PlaylistDB extends DBSchema {
113
+ playbackPositions: {
114
+ key: string;
115
+ value: PlaybackPositionRecord;
116
+ };
117
+ lastPlayed: {
118
+ key: string;
119
+ value: LastPlayedRecord;
120
+ };
121
+ settings: {
122
+ key: string;
123
+ value: SettingRecord;
124
+ };
125
+ docIndex: {
126
+ key: string; // docId (AutomergeUrl)
127
+ value: DocIndexEntry;
128
+ };
129
+ knocks: {
130
+ key: string; // id
131
+ value: KnockRecord;
132
+ };
133
+ accessGrants: {
134
+ key: string; // nodeId
135
+ value: AccessGrantRecord;
136
+ };
137
+ }
138
+
139
+ // database connection cache
140
+ let cachedDB: Promise<IDBPDatabase<PlaylistDB>> | null = null;
141
+
142
+ export async function setupDB(): Promise<IDBPDatabase<PlaylistDB>> {
143
+ if (cachedDB) {
144
+ return cachedDB;
145
+ }
146
+
147
+ cachedDB = openDB<PlaylistDB>(DB_NAME, DB_VERSION, {
148
+ upgrade(db) {
149
+ if (!db.objectStoreNames.contains(PLAYBACK_POSITIONS_STORE)) {
150
+ db.createObjectStore(PLAYBACK_POSITIONS_STORE, { keyPath: "songId" });
151
+ }
152
+ if (!db.objectStoreNames.contains(LAST_PLAYED_STORE)) {
153
+ db.createObjectStore(LAST_PLAYED_STORE, { keyPath: "playlistId" });
154
+ }
155
+ if (!db.objectStoreNames.contains(SETTINGS_STORE)) {
156
+ db.createObjectStore(SETTINGS_STORE, { keyPath: "key" });
157
+ }
158
+ if (!db.objectStoreNames.contains(DOC_INDEX_STORE)) {
159
+ db.createObjectStore(DOC_INDEX_STORE, { keyPath: "docId" });
160
+ }
161
+ if (!db.objectStoreNames.contains(KNOCKS_STORE)) {
162
+ db.createObjectStore(KNOCKS_STORE, { keyPath: "id" });
163
+ }
164
+ if (!db.objectStoreNames.contains(ACCESS_GRANTS_STORE)) {
165
+ db.createObjectStore(ACCESS_GRANTS_STORE, { keyPath: "nodeId" });
166
+ }
167
+ },
168
+ });
169
+
170
+ return cachedDB;
171
+ }
172
+
173
+ // reset the database cache - for testing only
174
+ export function resetDBCache(): void {
175
+ cachedDB = null;
176
+ }
177
+
178
+ // live query configuration
179
+ interface LiveQueryConfig {
180
+ dbName: string;
181
+ storeName: string;
182
+ queryFn?: (item: unknown) => boolean;
183
+ fields?: string[];
184
+ limit?: number | null;
185
+ }
186
+
187
+ function arraysDiffer<T>(a: T[], b: T[]): boolean {
188
+ if (a.length !== b.length) return true;
189
+ return a.some((item, index) => {
190
+ if (typeof item === "object" && item !== null && b[index] !== null) {
191
+ return JSON.stringify(item) !== JSON.stringify(b[index]);
192
+ }
193
+ return item !== b[index];
194
+ });
195
+ }
196
+
197
+ // global registry to track all live queries for direct same-tab updates
198
+ const globalQueryRegistry = new Map<string, Set<() => void>>();
199
+
200
+ export function createLiveQuery<T>({
201
+ dbName,
202
+ storeName,
203
+ queryFn,
204
+ fields = [],
205
+ limit = null,
206
+ }: LiveQueryConfig): Signal<T[]> {
207
+ const signal = createSignal<T[]>([]);
208
+ const bc = new BroadcastChannel(`${dbName}-changes`);
209
+ let last: T[] = [];
210
+
211
+ const validStores = [
212
+ PLAYBACK_POSITIONS_STORE,
213
+ LAST_PLAYED_STORE,
214
+ SETTINGS_STORE,
215
+ DOC_INDEX_STORE,
216
+ KNOCKS_STORE,
217
+ ACCESS_GRANTS_STORE,
218
+ ];
219
+
220
+ async function fetchAndUpdate() {
221
+ try {
222
+ if (!validStores.includes(storeName)) {
223
+ signal.set([]);
224
+ return;
225
+ }
226
+
227
+ const db = await setupDB();
228
+ let items = await (db as IDBPDatabase).getAll(storeName);
229
+
230
+ if (queryFn) items = items.filter(queryFn);
231
+ if (limit) items = items.slice(0, limit);
232
+
233
+ const filtered = items.map((item): T => {
234
+ if (fields.length === 0) return item as T;
235
+
236
+ const rec = item as Record<string, unknown>;
237
+ const out: Record<string, unknown> = {};
238
+ // copy whichever key field the record uses
239
+ if (typeof rec.id === "string") out.id = rec.id;
240
+ if (typeof rec.docId === "string") out.docId = rec.docId;
241
+ if (typeof rec.songId === "string") out.songId = rec.songId;
242
+ if (typeof rec.playlistId === "string") out.playlistId = rec.playlistId;
243
+ if (typeof rec.nodeId === "string") out.nodeId = rec.nodeId;
244
+ for (const f of fields) {
245
+ out[f] = rec[f];
246
+ }
247
+ return out as T;
248
+ });
249
+
250
+ if (arraysDiffer(last, filtered)) {
251
+ last = filtered;
252
+ signal.set(filtered);
253
+ }
254
+ } catch (error) {
255
+ console.error("error in fetchandupdate:", error);
256
+ }
257
+ }
258
+
259
+ const registryKey = `${dbName}-${storeName}`;
260
+ if (!globalQueryRegistry.has(registryKey)) {
261
+ globalQueryRegistry.set(registryKey, new Set());
262
+ }
263
+ const querySet = globalQueryRegistry.get(registryKey)!;
264
+ querySet.add(fetchAndUpdate);
265
+
266
+ bc.onmessage = (e) => {
267
+ if (e.data?.type === "mutation" && e.data.store === storeName) {
268
+ fetchAndUpdate();
269
+ }
270
+ };
271
+
272
+ fetchAndUpdate();
273
+
274
+ const originalSignal = signal;
275
+ return {
276
+ ...originalSignal,
277
+ subscribe: (fn: (value: T[]) => void) => {
278
+ const unsubscribe = originalSignal.subscribe(fn);
279
+ return () => {
280
+ unsubscribe();
281
+ querySet.delete(fetchAndUpdate);
282
+ if (querySet.size === 0) {
283
+ globalQueryRegistry.delete(registryKey);
284
+ }
285
+ bc.close();
286
+ };
287
+ },
288
+ };
289
+ }
290
+
291
+ // --- playback positions ---
292
+
293
+ export async function loadAllPlaybackPositions(): Promise<Map<string, number>> {
294
+ try {
295
+ const db = await setupDB();
296
+ const records = await db.getAll(PLAYBACK_POSITIONS_STORE);
297
+ const map = new Map<string, number>();
298
+ for (const r of records) {
299
+ map.set(r.songId, r.position);
300
+ }
301
+ return map;
302
+ } catch (error) {
303
+ console.warn("error loading playback positions:", error);
304
+ return new Map();
305
+ }
306
+ }
307
+
308
+ export async function savePlaybackPosition(
309
+ songId: string,
310
+ position: number
311
+ ): Promise<void> {
312
+ try {
313
+ const db = await setupDB();
314
+ await db.put(PLAYBACK_POSITIONS_STORE, {
315
+ songId,
316
+ position,
317
+ updatedAt: Date.now(),
318
+ });
319
+ } catch (error) {
320
+ console.warn(`error saving playback position for ${songId}:`, error);
321
+ }
322
+ }
323
+
324
+ export async function deletePlaybackPosition(songId: string): Promise<void> {
325
+ try {
326
+ const db = await setupDB();
327
+ await db.delete(PLAYBACK_POSITIONS_STORE, songId);
328
+ } catch (error) {
329
+ console.warn(`error deleting playback position for ${songId}:`, error);
330
+ }
331
+ }
332
+
333
+ // --- last played ---
334
+
335
+ export async function saveLastPlayed(
336
+ playlistId: string,
337
+ songId: string
338
+ ): Promise<void> {
339
+ try {
340
+ const db = await setupDB();
341
+ await db.put(LAST_PLAYED_STORE, {
342
+ playlistId,
343
+ songId,
344
+ updatedAt: Date.now(),
345
+ });
346
+ } catch (error) {
347
+ console.warn(`error saving last played for playlist ${playlistId}:`, error);
348
+ }
349
+ }
350
+
351
+ export async function loadLastPlayed(
352
+ playlistId: string
353
+ ): Promise<string | null> {
354
+ try {
355
+ const db = await setupDB();
356
+ const record = await db.get(LAST_PLAYED_STORE, playlistId);
357
+ return record?.songId ?? null;
358
+ } catch (error) {
359
+ console.warn(
360
+ `error loading last played for playlist ${playlistId}:`,
361
+ error
362
+ );
363
+ return null;
364
+ }
365
+ }
366
+
367
+ // --- settings ---
368
+
369
+ export async function saveSetting(key: string, value: unknown): Promise<void> {
370
+ try {
371
+ const db = await setupDB();
372
+ await db.put(SETTINGS_STORE, { key, value, updatedAt: Date.now() });
373
+ } catch (error) {
374
+ console.warn(`error saving setting ${key}:`, error);
375
+ }
376
+ }
377
+
378
+ export async function loadSetting<T>(key: string): Promise<T | null> {
379
+ try {
380
+ const db = await setupDB();
381
+ const record = await db.get(SETTINGS_STORE, key);
382
+ return record === undefined ? null : (record.value as T);
383
+ } catch (error) {
384
+ console.warn(`error loading setting ${key}:`, error);
385
+ return null;
386
+ }
387
+ }
388
+
389
+ // --- compatibility stubs ---
390
+ // kept for services that get minimal-compile-fix treatment only:
391
+ // standaloneService, playlistDownloadService, streamingAudioService.
392
+ // they are no-ops at runtime; real implementations are in playlistDocService.
393
+
394
+ import type { Playlist, Song } from "../types/playlist.js";
395
+
396
+ export interface MutationConfig<T> {
397
+ dbName: string;
398
+ storeName: string;
399
+ key: string;
400
+ updateFn: (current: T | null) => T;
401
+ }
402
+
403
+ // no-op stub - real mutations go through playlistDocService
404
+ export async function mutateAndNotify<T extends Playlist | Song>(
405
+ _config: MutationConfig<T>
406
+ ): Promise<void> {
407
+ console.warn(
408
+ "mutateandnotify: called on stub - data is doc-backed, use playlistDocService"
409
+ );
410
+ }
411
+
412
+ // stub - returns empty array; real impl in playlistDocService
413
+ export async function getSongsWithAudioData(
414
+ _songIds: string[]
415
+ ): Promise<Song[]> {
416
+ console.warn(
417
+ "getsongswithaudiodata: stub - use playlistDocService instead"
418
+ );
419
+ return [];
420
+ }
421
+
422
+ // stub - no-op
423
+ export async function updatePlaylist(
424
+ _id: string,
425
+ _updates: Partial<Playlist>
426
+ ): Promise<void> {
427
+ console.warn("updateplaylist: stub - use playlistDocService instead");
428
+ }
429
+
430
+ // stub - no-op
431
+ export async function updateSong(
432
+ _id: string,
433
+ _updates: Partial<Song>
434
+ ): Promise<void> {
435
+ console.warn("updatesong: stub - use playlistDocService instead");
436
+ }