@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,344 @@
1
+ // p2p bootstrap service for playlistz.
2
+ //
3
+ // wires identity resolution (from freqhole-api-client/storage) with midden
4
+ // node lifecycle and web locks leader election.
5
+ //
6
+ // nothing wasm-related runs at import time. call startP2P() explicitly.
7
+
8
+ import { openDB } from "idb";
9
+ import {
10
+ resolveIdentity,
11
+ persistIdentity,
12
+ acquireNodeLeadership,
13
+ type P2PIdentity,
14
+ type IdentityStore,
15
+ } from "@freqhole/api-client/storage";
16
+ import { AUTOMERGE_ALPN, PLAYLISTZ_ALPN } from "@freqhole/api-client/playlistz";
17
+ import type {
18
+ MiddenStreamNode,
19
+ IrohNetworkAdapterOptions,
20
+ } from "@freqhole/api-client/automerge";
21
+
22
+ // --- local settings db for identity fallback ---
23
+
24
+ const SETTINGS_DB_NAME = "freqhole-playlistz-settings";
25
+ const SETTINGS_STORE = "settings";
26
+ const IDENTITY_KEY = "p2p_identity";
27
+
28
+ function createLocalStore(): IdentityStore {
29
+ let db: Awaited<ReturnType<typeof openDB>> | null = null;
30
+
31
+ async function getDb(): Promise<Awaited<ReturnType<typeof openDB>>> {
32
+ if (!db) {
33
+ db = await openDB(SETTINGS_DB_NAME, 1, {
34
+ upgrade(database) {
35
+ if (!database.objectStoreNames.contains(SETTINGS_STORE)) {
36
+ database.createObjectStore(SETTINGS_STORE);
37
+ }
38
+ },
39
+ });
40
+ }
41
+ return db;
42
+ }
43
+
44
+ return {
45
+ async get(): Promise<P2PIdentity | null> {
46
+ const database = await getDb();
47
+ const result = await database.get(SETTINGS_STORE, IDENTITY_KEY);
48
+ return (result as P2PIdentity) ?? null;
49
+ },
50
+ async set(identity: P2PIdentity): Promise<void> {
51
+ const database = await getDb();
52
+ await database.put(SETTINGS_STORE, identity, IDENTITY_KEY);
53
+ },
54
+ };
55
+ }
56
+
57
+ // --- module-level singleton state ---
58
+
59
+ let _localStore: IdentityStore | null = null;
60
+
61
+ function getLocalStore(): IdentityStore {
62
+ if (!_localStore) {
63
+ _localStore = createLocalStore();
64
+ }
65
+ return _localStore;
66
+ }
67
+
68
+ let currentIdentity: P2PIdentity | null = null;
69
+ let currentNode: MiddenStreamNode | null = null;
70
+ let leaderState = false;
71
+ let started = false;
72
+ let cancelLeadership: (() => void) | null = null;
73
+
74
+ // leadership phase: "unknown" until the lock request settles, then
75
+ // "leader" / "waiting" / "unsupported". used by waitForNode to short-circuit
76
+ // in tabs that will never hold the node
77
+ let leadershipPhase: "unknown" | "leader" | "waiting" | "unsupported" =
78
+ "unknown";
79
+
80
+ // resolved when the local node comes up (or definitively won't)
81
+ const nodeWaiters = new Set<(node: MiddenStreamNode | null) => void>();
82
+
83
+ function flushNodeWaiters(node: MiddenStreamNode | null): void {
84
+ for (const resolve of nodeWaiters) resolve(node);
85
+ nodeWaiters.clear();
86
+ }
87
+
88
+ const identityListeners = new Set<(identity: P2PIdentity | null) => void>();
89
+ const leadershipListeners = new Set<(isLeader: boolean) => void>();
90
+
91
+ function notifyIdentityListeners(): void {
92
+ for (const cb of identityListeners) {
93
+ try {
94
+ cb(currentIdentity);
95
+ } catch {
96
+ // ignore listener errors
97
+ }
98
+ }
99
+ }
100
+
101
+ function notifyLeadershipListeners(): void {
102
+ for (const cb of leadershipListeners) {
103
+ try {
104
+ cb(leaderState);
105
+ } catch {
106
+ // ignore listener errors
107
+ }
108
+ }
109
+ }
110
+
111
+ // --- internal helpers ---
112
+
113
+ async function resolveOrCreateIdentity(): Promise<P2PIdentity> {
114
+ const existing = await resolveIdentity(getLocalStore());
115
+ if (existing) return existing;
116
+
117
+ // no identity found anywhere - generate a new one.
118
+ // node_id is filled in with the real iroh public key after midden boots.
119
+ const secretKey = new Uint8Array(32);
120
+ crypto.getRandomValues(secretKey);
121
+ const newIdentity: P2PIdentity = {
122
+ id: "p2p_identity",
123
+ secret_key: secretKey,
124
+ node_id: "",
125
+ created_at: Date.now(),
126
+ };
127
+
128
+ await persistIdentity(newIdentity, getLocalStore());
129
+ return newIdentity;
130
+ }
131
+
132
+ async function bootMidden(
133
+ secretKey: Uint8Array
134
+ ): Promise<MiddenStreamNode | null> {
135
+ try {
136
+ // bundler target: wasm init happens at import time via vite-plugin-wasm.
137
+ const midden = await import("@freqhole/midden");
138
+ const node = await midden.MiddenNode.create_with_alpns(secretKey, [
139
+ AUTOMERGE_ALPN,
140
+ PLAYLISTZ_ALPN,
141
+ ]);
142
+ return node as unknown as MiddenStreamNode;
143
+ } catch (err) {
144
+ console.warn("[p2p] midden boot failed - p2p unavailable:", err);
145
+ return null;
146
+ }
147
+ }
148
+
149
+ // --- public api ---
150
+
151
+ /**
152
+ * start the p2p subsystem.
153
+ * resolves identity, acquires leadership, and boots the midden node if leader.
154
+ * safe to call multiple times - no-ops if already started.
155
+ */
156
+ export async function startP2P(): Promise<void> {
157
+ if (started) return;
158
+ started = true;
159
+
160
+ try {
161
+ currentIdentity = await resolveOrCreateIdentity();
162
+ notifyIdentityListeners();
163
+ } catch (err) {
164
+ console.warn("[p2p] identity resolution failed:", err);
165
+ started = false;
166
+ return;
167
+ }
168
+
169
+ const identityAtStart = currentIdentity;
170
+
171
+ cancelLeadership = acquireNodeLeadership({
172
+ onAcquired: async () => {
173
+ leaderState = true;
174
+ leadershipPhase = "leader";
175
+ notifyLeadershipListeners();
176
+
177
+ const node = await bootMidden(identityAtStart.secret_key);
178
+ if (node) {
179
+ // expose the node BEFORE notifying listeners - the iroh adapter's
180
+ // identity listener calls getNode() and would otherwise throw
181
+ currentNode = node;
182
+
183
+ // update node_id from the real iroh public key
184
+ const realNodeId = node.node_id();
185
+ if (realNodeId !== identityAtStart.node_id) {
186
+ currentIdentity = { ...identityAtStart, node_id: realNodeId };
187
+ try {
188
+ await persistIdentity(currentIdentity, getLocalStore());
189
+ } catch {
190
+ // non-fatal: node_id update will be retried on next boot
191
+ }
192
+ }
193
+ // always notify: listeners subscribed before leadership was acquired
194
+ // need to learn the node is now available
195
+ notifyIdentityListeners();
196
+ }
197
+ flushNodeWaiters(node);
198
+ },
199
+ onStateChange: (state) => {
200
+ if (state === "waiting") {
201
+ leadershipPhase = "waiting";
202
+ // another tab holds the node - local waiters resolve null
203
+ flushNodeWaiters(null);
204
+ } else if (state === "unsupported") {
205
+ leadershipPhase = "unsupported";
206
+ }
207
+ if (state !== "leader" && leaderState) {
208
+ leaderState = false;
209
+ notifyLeadershipListeners();
210
+ }
211
+ },
212
+ });
213
+ }
214
+
215
+ /**
216
+ * stop p2p: release the leadership lock and clear the node reference.
217
+ */
218
+ export function stopP2P(): void {
219
+ started = false;
220
+ cancelLeadership?.();
221
+ cancelLeadership = null;
222
+ currentNode = null;
223
+ if (leaderState) {
224
+ leaderState = false;
225
+ notifyLeadershipListeners();
226
+ }
227
+ }
228
+
229
+ /** get the running midden node, or null if not leader or not yet started. */
230
+ export function getNode(): MiddenStreamNode | null {
231
+ return currentNode;
232
+ }
233
+
234
+ /**
235
+ * wait for the local midden node to come up after startP2P.
236
+ * resolves with the node once booted, or null when this tab is not the
237
+ * leader (another tab holds the node), p2p was never started, or the
238
+ * timeout elapses.
239
+ */
240
+ export function waitForNode(
241
+ timeoutMs = 30000
242
+ ): Promise<MiddenStreamNode | null> {
243
+ if (currentNode) return Promise.resolve(currentNode);
244
+ if (!started || leadershipPhase === "waiting") {
245
+ return Promise.resolve(null);
246
+ }
247
+ return new Promise((resolve) => {
248
+ const timer = setTimeout(() => {
249
+ nodeWaiters.delete(waiter);
250
+ resolve(null);
251
+ }, timeoutMs);
252
+ const waiter = (node: MiddenStreamNode | null) => {
253
+ clearTimeout(timer);
254
+ resolve(node);
255
+ };
256
+ nodeWaiters.add(waiter);
257
+ });
258
+ }
259
+
260
+ /** get the current resolved identity, or null if not yet resolved. */
261
+ export function getIdentity(): P2PIdentity | null {
262
+ return currentIdentity;
263
+ }
264
+
265
+ /**
266
+ * check whether an identity is already persisted (without creating one).
267
+ * used to auto-resume p2p on boot only for users who have enabled it.
268
+ */
269
+ export async function hasExistingIdentity(): Promise<boolean> {
270
+ try {
271
+ const existing = await resolveIdentity(getLocalStore());
272
+ return existing !== null;
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ /** returns true if this tab holds the iroh node leadership lock. */
279
+ export function isLeader(): boolean {
280
+ return leaderState;
281
+ }
282
+
283
+ /**
284
+ * subscribe to leadership state changes.
285
+ * calls cb immediately with current state. returns an unsubscribe function.
286
+ */
287
+ export function onLeadershipChange(
288
+ cb: (isLeader: boolean) => void
289
+ ): () => void {
290
+ leadershipListeners.add(cb);
291
+ cb(leaderState);
292
+ return () => {
293
+ leadershipListeners.delete(cb);
294
+ };
295
+ }
296
+
297
+ /**
298
+ * subscribe to identity changes. returns an unsubscribe function.
299
+ * the signature satisfies IrohNetworkAdapterOptions.onIdentityChange.
300
+ */
301
+ export function onIdentityChange(
302
+ cb: (identity: P2PIdentity | null) => void
303
+ ): () => void {
304
+ identityListeners.add(cb);
305
+ return () => {
306
+ identityListeners.delete(cb);
307
+ };
308
+ }
309
+
310
+ /**
311
+ * returns options suitable for constructing an IrohNetworkAdapter.
312
+ * pass directly to new IrohNetworkAdapter(getAdapterOptions()).
313
+ */
314
+ export function getAdapterOptions(): IrohNetworkAdapterOptions {
315
+ return {
316
+ getNode: async () => {
317
+ if (!currentNode) {
318
+ throw new Error(
319
+ "p2p: midden node is not available (not leader or not started)"
320
+ );
321
+ }
322
+ return currentNode;
323
+ },
324
+ getIdentity: async () => currentIdentity,
325
+ onIdentityChange: (cb) => onIdentityChange(cb),
326
+ syncAlpn: AUTOMERGE_ALPN,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * reset all module state. for use in tests only.
332
+ */
333
+ export function _resetForTests(): void {
334
+ started = false;
335
+ currentIdentity = null;
336
+ currentNode = null;
337
+ leaderState = false;
338
+ leadershipPhase = "unknown";
339
+ cancelLeadership = null;
340
+ _localStore = null;
341
+ identityListeners.clear();
342
+ leadershipListeners.clear();
343
+ nodeWaiters.clear();
344
+ }