@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
package/dist/sw.js ADDED
@@ -0,0 +1,134 @@
1
+ // playlistz service worker
2
+ //
3
+ // caches the app shell files (freqhole-playlistz.js, playlistz.js, index.html, sw.js).
4
+ // audio and image files in data/ are NOT pre-cached; the app ui handles that separately.
5
+ //
6
+ // bumping CACHE_VERSION forces all clients to receive fresh app files on next load.
7
+ const CACHE_VERSION = "v2";
8
+ const CACHE_NAME = `playlistz-${CACHE_VERSION}`;
9
+
10
+ // app shell files to cache on install
11
+ const APP_SHELL = [
12
+ "freqhole-playlistz.js",
13
+ "playlistz.js",
14
+ "index.html",
15
+ "sw.js",
16
+ ];
17
+
18
+ // install: cache app shell and skip waiting so this sw activates immediately
19
+ self.addEventListener("install", (event) => {
20
+ event.waitUntil(
21
+ caches.open(CACHE_NAME).then((cache) =>
22
+ cache.addAll(APP_SHELL).catch((err) => {
23
+ // some files may not exist (e.g. playlistz.js before first generate run)
24
+ console.warn("playlistz sw: pre-cache partial failure:", err);
25
+ }),
26
+ ).then(() => self.skipWaiting()),
27
+ );
28
+ });
29
+
30
+ // activate: delete old caches, claim all clients immediately
31
+ self.addEventListener("activate", (event) => {
32
+ event.waitUntil(
33
+ caches.keys()
34
+ .then((names) =>
35
+ Promise.all(
36
+ names
37
+ .filter((n) => n.startsWith("playlistz-") && n !== CACHE_NAME)
38
+ .map((n) => caches.delete(n)),
39
+ ),
40
+ )
41
+ .then(() => self.clients.claim()),
42
+ );
43
+ });
44
+
45
+ // fetch: cache-first for app shell files, network-pass-through for everything else
46
+ self.addEventListener("fetch", (event) => {
47
+ const url = new URL(event.request.url);
48
+ const filename = url.pathname.split("/").pop() ?? "";
49
+ const isAppShell = APP_SHELL.includes(filename) || url.pathname === "/" || url.pathname === "";
50
+
51
+ if (!isAppShell) {
52
+ return; // let browser handle data/ assets (audio, images)
53
+ }
54
+
55
+ event.respondWith(
56
+ caches.match(event.request).then((cached) => {
57
+ if (cached) return cached;
58
+ return fetch(event.request).then((response) => {
59
+ if (response.ok) {
60
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, response.clone()));
61
+ }
62
+ return response;
63
+ });
64
+ }).catch(() => caches.match("index.html")),
65
+ );
66
+ });
67
+
68
+ // message: handle reset/clear from the page via window.__playlistzReset()
69
+ self.addEventListener("message", (event) => {
70
+ if (event.data?.type === "PLAYLISTZ_RESET") {
71
+ event.waitUntil(
72
+ caches.keys()
73
+ .then((names) => Promise.all(names.map((n) => caches.delete(n))))
74
+ .then(() => self.clients.matchAll())
75
+ .then((clients) => clients.forEach((c) => c.postMessage({ type: "PLAYLISTZ_RESET_DONE" }))),
76
+ );
77
+ }
78
+ });
79
+
80
+
81
+ self.addEventListener("install", (event) => {
82
+ console.log("playlistz service worker: installing...");
83
+ event.waitUntil(
84
+ caches
85
+ .open(CACHE_NAME)
86
+ .then((cache) => {
87
+ console.log("playlistz service worker: caching app shell");
88
+ return cache.addAll(urlsToCache);
89
+ })
90
+ .catch((error) => {
91
+ console.error(
92
+ "playlistz service worker: failed to cache app shell:",
93
+ error
94
+ );
95
+ })
96
+ );
97
+ });
98
+
99
+ self.addEventListener("fetch", (event) => {
100
+ event.respondWith(
101
+ caches
102
+ .match(event.request)
103
+ .then((response) => {
104
+ // return cached version or fetch from network
105
+ if (response) {
106
+ return response;
107
+ }
108
+ return fetch(event.request);
109
+ })
110
+ .catch((error) => {
111
+ console.error("playlistz service worker: fetch failed:", error);
112
+ throw error;
113
+ })
114
+ );
115
+ });
116
+
117
+ self.addEventListener("activate", (event) => {
118
+ console.log("playlistz service worker: activating...");
119
+ event.waitUntil(
120
+ caches.keys().then((cacheNames) => {
121
+ return Promise.all(
122
+ cacheNames.map((cacheName) => {
123
+ if (cacheName !== CACHE_NAME) {
124
+ console.log(
125
+ "playlistz service worker: deleting old cache:",
126
+ cacheName
127
+ );
128
+ return caches.delete(cacheName);
129
+ }
130
+ })
131
+ );
132
+ })
133
+ );
134
+ });
@@ -0,0 +1,233 @@
1
+ # automerge p2p plan: playlist docs over iroh
2
+
3
+ each playlist becomes an automerge document, synced peer-to-peer over iroh using skein's proven stack (automerge-repo + a midden BiStream network adapter). this supersedes the sync-protocol portions of [IROH_P2P_PLAN.md](IROH_P2P_PLAN.md): instead of hand-rolling a freqhole-playlistz request/response protocol for playlist data and mirroring spume's indexeddb stores, playlist state is a CRDT that merges automatically between any number of peers. several sections of the old plan carry over unchanged and are referenced below rather than duplicated.
4
+
5
+ ## why automerge docs
6
+
7
+ - multi-node playlist sync (the hard part of the old plan: diffing, conflict handling, partial updates, re-sync after offline) is automerge-repo's entire job.
8
+ - clean separation of concerns with spume: playlistz does not adopt spume's `freqhole_music` idb schema. instead, a central set of schemas in `freqhole-api-client` defines the playlist doc shape, and small converter utils map spume/freqhole records -> playlist docs (and back). interop is at the schema level, not the storage level.
9
+ - freqhole interop still works through the `freqhole-playlistz/1` ALPN: a freqhole node answers a small JSON protocol (list playlists, knock), and the actual playlist payloads convert into local automerge docs on the playlistz side.
10
+ - skein has already validated the whole stack in production: automerge-repo v2 over iroh QUIC via wasm, CBOR framing, reconnect/backoff, share strings, even a headless hub peer (reliquary) for always-on availability.
11
+
12
+ ## reference map
13
+
14
+ ### skein/ (the stack we adopt)
15
+
16
+ | what | where |
17
+ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
18
+ | midden wasm node (iroh 0.97, BiStream, accept loop, iroh-blobs, `create_with_alpns`) | `skein/midden/src/lib.rs` |
19
+ | automerge ALPN constant `iroh/automerge-repo/1` | `skein/midden/src/lib.rs`, `skein/loam/src/p2p/iroh-network-adapter.ts` |
20
+ | `IrohNetworkAdapter` (automerge-repo NetworkAdapter over BiStream: CBOR-encoded `Message`s, 4-byte BE length frames, reconnect backoff, alpn handler registry, connection summary for UI) | `skein/loam/src/p2p/iroh-network-adapter.ts` |
21
+ | identity persistence (`p2p_identity` record `{ secret_key, node_id, created_at }` in a small kv idb, lazy midden start) | `skein/loam/src/p2p/identity.ts`, `skein/loam/src/storage/meta-db.ts` |
22
+ | repo wiring (`new Repo({ storage: IndexedDBStorageAdapter, network: [BroadcastChannelNetworkAdapter, irohAdapter] })`) | `skein/loam/src/standalone/boot.ts` |
23
+ | share strings (`base64({ n: nodeId, d: docId })`, url fragment `#share/<b64>`) | `skein/loam/src/p2p/share-string.ts` |
24
+ | doc facade pattern (zod-validated reads over a DocHandle, `change()` + `on("change")`) | `skein/loam/src/widgets/widget-doc.ts` |
25
+ | blob worker (blake3/sha256 hashing + opfs writes off the main thread) | `skein/loam/src/workers/blob-worker.ts` |
26
+ | reliquary headless hub peer (rust, sqlite-persisted automerge docs, serves sync + blobs while browsers are offline) | `skein/reliquary/` |
27
+
28
+ ### carried over from IROH_P2P_PLAN.md (still authoritative there)
29
+
30
+ - guiding principle: one shared storage/p2p layer in `freqhole-api-client`.
31
+ - midden packaging: vite wasm plugins, single-file standalone attempt (`build-web` target, base64-inline wasm).
32
+ - identity interop with spume: read-only `freqhole_app` access rules, fallback to local settings store.
33
+ - single live node per origin: web locks leader election + share panel lock status.
34
+ - image/audio blob bytes: shared opfs blob store (`/blobs/{sha256}`, `freqhole_blobs` metadata db, Cache API fallback) extracted from spume into `freqhole-api-client/storage`.
35
+ - blob transfer: iroh-blobs verified streaming (`download_verified_streaming`, `import_blob`/`release_blob` via `WasmTransport`).
36
+ - knock concept and share panel UX (adapted below to the doc model).
37
+
38
+ ## architecture decisions
39
+
40
+ ### packages
41
+
42
+ - new deps: `@automerge/automerge`, `@automerge/automerge-repo`, `@automerge/automerge-repo-storage-indexeddb`, `@automerge/automerge-repo-network-broadcastchannel` (skein uses `^3.2.5` / `^2.5.5`; match those).
43
+ - midden: use tomb's midden (`file:../tomb/client/midden/pkg`), NOT skein's. automerge sync needs no rust-side support - the adapter drives everything in JS over generic BiStreams - so the only requirement is registering the ALPN string: `create_with_alpns(key, ["iroh/automerge-repo/1", "freqhole-playlistz/1"])`. tomb's midden already has BiStream/open_bi/accept with identical framing (both middens descend from the same code).
44
+ - `freqhole-api-client` gains a `schemas` (or extends `storage`) subpath: zod schema for the playlist doc, `ImageRef`/`EntityUrl`/`BlobKind`, and converter utils (`spumePlaylistToDoc`, `freqholePlaylistToDoc`, `docToFreqholePlaylist`...). this is the single source of truth both apps and future converters share.
45
+ - the `IrohNetworkAdapter` is ported from skein into `freqhole-api-client` (parameterized over the identity getter instead of importing skein's identity module) so spume can later adopt the same adapter. skein keeps its own copy for now; convergence is a "later" item coordinated across repos.
46
+
47
+ ### shared package layout (freqhole-api-client)
48
+
49
+ goal: enough playlistz domain logic lives in `freqhole-api-client` + midden that any tomb client (spume, charnel) or skein can "talk playlistz" by importing these subpaths. playlistz itself keeps only solid reactivity, repo wiring, and UI.
50
+
51
+ ```
52
+ tomb/client-codegen/freqhole-api-client/
53
+ package.json # subpath exports added (raw TS, no build step)
54
+ src/
55
+ storage/ # shared browser storage layer
56
+ blobs.ts # extracted verbatim from spume src/music/services/storage/blobs.ts
57
+ identity.ts # p2p_identity resolution (read-only freqhole_app + local fallback)
58
+ webLocks.ts # "freqhole-iroh-node" leader election helper
59
+ index.ts
60
+ playlistz/ # the "talk playlistz" domain package
61
+ schema.ts # PlaylistDoc/SongEntry/ImageRef/EntityUrl/BlobKind zod schemas + parseDoc helper
62
+ mutations.ts # pure (doc, args) => void helpers usable inside handle.change:
63
+ # upsertSong, removeSong, reorderSongs, setPrimaryImage, addImage,
64
+ # setMetadata, addPeer, stampLastSeen, setAclRole, tombstone
65
+ convert.ts # spumePlaylistToDoc, freqholePlaylistToDoc, docToFreqholePlaylist
66
+ shareLink.ts # encode/decode base64url { v, n, d, t? } + #share/ fragment helpers
67
+ protocol.ts # freqhole-playlistz/1 zod message schemas (hello, list_playlists,
68
+ # knock, knock_status, error) + BiStream encode/decode helpers
69
+ index.ts
70
+ automerge/ # transport-level automerge-over-iroh
71
+ IrohNetworkAdapter.ts # ported from skein loam, identity getter + ALPN injected via constructor
72
+ index.ts
73
+ ```
74
+
75
+ - `package.json` exports map: `"."` (unchanged), `"./storage"`, `"./playlistz"`, `"./automerge"` -> the respective `src/*/index.ts`.
76
+ - dependency policy: `@automerge/automerge-repo` becomes a **peerDependency** (consuming apps own the version; the adapter only imports `NetworkAdapter`, `cbor`, and types). `zod` stays a regular dep. `storage/` and `playlistz/` must not import automerge - mutation helpers are plain functions over plain objects, which is what keeps them shareable.
77
+ - `src/codegen/` stays codegen-owned (never hand-edit `schema.ts`/`routes.ts`); the new subdirs are hand-written and import from codegen types where useful.
78
+ - existing tests run via `npm test` (`tsx src/test.ts`, custom runner under `src/test/`); new modules follow that convention.
79
+
80
+ ### document model
81
+
82
+ one automerge doc per playlist. the doc id (automerge document id) is the playlist's global identity.
83
+
84
+ ```ts
85
+ interface PlaylistDoc {
86
+ version: 1;
87
+ title: string;
88
+ description: string;
89
+ createdAt: string; // ISO
90
+ lastModified: string; // ISO
91
+ lastModifiedBy: string; // node id
92
+ images: ImageRef[]; // { blobId: sha256, isPrimary, blobType }
93
+ urls: EntityUrl[];
94
+ songs: Record<string, SongEntry>; // keyed by song id
95
+ order: string[]; // song ids, automerge list (move-friendly)
96
+ peers: Record<
97
+ string,
98
+ { nodeId: string; joinedAt: string; lastSeenAt?: string }
99
+ >;
100
+ acl?: Record<string, { role: "owner" | "editor" | "viewer" }>;
101
+ deleted?: boolean; // tombstone
102
+ }
103
+
104
+ interface SongEntry {
105
+ id: string;
106
+ title: string;
107
+ artist: string;
108
+ album: string;
109
+ duration: number;
110
+ mimeType: string;
111
+ fileSize: number;
112
+ sha256: string; // content identity, blob store key
113
+ blake3?: string; // iroh-blobs transfer hash
114
+ images: ImageRef[];
115
+ urls: EntityUrl[];
116
+ lyrics?: string;
117
+ }
118
+ ```
119
+
120
+ - binary payloads (audio, images, waveforms) are NOT in the doc - docs carry hashes; bytes live in the shared opfs blob store and transfer via iroh-blobs. this is exactly skein's file-widget pattern.
121
+ - field shapes match the freqhole wire schemas (camelCase here, converters handle `is_primary` 0/1 etc.) so spume idb -> playlist doc is a flat mapping.
122
+ - doc reads go through a zod-validated facade (skein's `createWidgetDoc` pattern): corrupt or future-versioned peer data degrades to defaults instead of crashing.
123
+
124
+ ### local storage layout
125
+
126
+ - NO MIGRATION: playlistz has no deployed users, so the idb schema is greenfield - no upgrade paths from the v6 layout, no legacy-record converters, no dual-path fallbacks. the old `playlists`/`songs` stores are simply gone.
127
+ - automerge-repo persists doc storage via `IndexedDBStorageAdapter` in a db named `freqhole-automerge` (explicit name so a future spume adoption shares the same repo storage on a shared domain).
128
+ - `musicPlaylistDB` restarts at version 1 with only non-shared state: `playbackPositions`, `lastPlayed`, `settings`, `knocks`, `accessGrants`, plus a `docIndex` store mapping automerge doc id -> `{ title, addedAt, source: "local" | "shared" | "freqhole" }` for the sidebar listing without loading every doc.
129
+ - all binary bytes (audio, images, waveforms) go straight to the shared opfs blob store keyed by sha256 - no inline `audioData`/`imageData` anywhere. one accessor: sha256 -> blob store -> object url.
130
+
131
+ ### reactivity
132
+
133
+ the live-query system (`createPlaylistsQuery`/`createPlaylistSongsQuery` + BroadcastChannel) is replaced for playlist/song state by a thin solid adapter over doc handles:
134
+
135
+ - `createDocStore(handle)`: solid signal updated from `handle.on("change")`, zod-parsed. cross-tab updates arrive via `BroadcastChannelNetworkAdapter` (automerge-repo syncs between tabs natively - this replaces the manual BroadcastChannel invalidation, and the whitelist-stripping bug class disappears entirely).
136
+ - sidebar lists from the `docIndex` store (small, still a live query); opening a playlist materializes its doc store.
137
+ - mutations become `handle.change(doc => ...)` calls inside the existing manager hooks; component props/interfaces stay the same so UI churn is contained to the data layer.
138
+
139
+ ### network + sharing
140
+
141
+ - repo network: `[BroadcastChannelNetworkAdapter, IrohNetworkAdapter]`. the iroh adapter lazily starts midden only when an identity exists (skein's deferred-start pattern), gated by the web locks leader election from the old plan.
142
+ - share links: skein's format, playlistz-flavored: `base64url({ v: 1, n: nodeId, d: docId, t?: title })`, url fragment `#share/<b64>`. opening one: `repo.find(docId)` + `irohAdapter.addPeer(nodeId)`; automerge-repo syncs the doc from the peer, write `docIndex` entry, done. no import step, and edits flow both ways thereafter.
143
+ - access model: the doc id is an unguessable bearer capability (same trust model as spume's share tokens). repo `sharePolicy` controls proactive announcement: only announce docs to peers recorded in that doc's `peers`/acl. revocation = acl edit + tombstone awareness in UI (true cryptographic revocation is out of scope, as it is in skein).
144
+ - knock flow (for "browse this node's playlists" rather than a direct doc link): small JSON protocol on `freqhole-playlistz/1` - `hello`, `list_playlists` (returns doc ids + titles for public docs, or knock-gated), `knock`, `knock_status`. accepted knocks grant doc ids; sync then happens over the automerge ALPN. knock records + grants in `musicPlaylistDB` (knocks/accessGrants stores from the old plan, unchanged).
145
+ - per-playlist ephemeral presence (who's listening, now-playing) is possible later via `handle.broadcast` - designed for, not built now.
146
+
147
+ ### freqhole interop
148
+
149
+ - tomb side: grimoire/freqhole server answers `freqhole-playlistz/1` (list/knock as above) and serves blobs via iroh-blobs - same tomb-side work items as the old plan.
150
+ - importing a freqhole playlist: fetch its records over the protocol, run `freqholePlaylistToDoc` from the shared schemas package, create a local automerge doc. the freqhole node does not speak automerge; it is a data source, and the doc becomes the live local copy.
151
+ - spume convergence: if/when spume wants doc-backed playlists, it reuses the schemas + adapter from `freqhole-api-client` and `spumePlaylistToDoc`. until then, nothing in tomb references playlistz.
152
+
153
+ ## implementation phases
154
+
155
+ note: the 26 pre-existing freqhole-api-client test failures (route/client coverage mismatches) have been fixed - `npm test` is fully green there (413 passed, 0 failed).
156
+
157
+ ### phase 1: shared schemas + converters (tomb)
158
+
159
+ - [x] `freqhole-api-client`: `PlaylistDoc`/`SongEntry`/`ImageRef`/`EntityUrl` zod schemas
160
+ - [x] converter utils: `spumePlaylistToDoc`, `freqholePlaylistToDoc` (+ inverse where lossless)
161
+ - [x] blob store extraction from spume (`storage/blobs.ts` verbatim) into `freqhole-api-client/storage`; spume re-exports
162
+ - [x] unit tests (schema round-trips, converter fixtures)
163
+
164
+ ### phase 2: midden packaging + identity (playlistz + tomb)
165
+
166
+ - [x] midden dep + vite wasm plugins; single-file standalone attempt (per old plan phase 2)
167
+ - [x] identity resolution + web locks helper in `freqhole-api-client/storage` (per old plan phase 3, unchanged)
168
+ - [x] tomb midden: `create_with_alpns` accepts arbitrary extras (verified - generic string array, no rust change needed); `PLAYLISTZ_ALPN` registered at runtime by playlistz `p2pService` via `create_with_alpns(key, [AUTOMERGE_ALPN, PLAYLISTZ_ALPN])`
169
+ - [ ] smoke test: node boots, automerge + playlistz ALPNs registered
170
+
171
+ ### phase 3: repo + doc layer (playlistz)
172
+
173
+ - [x] port `IrohNetworkAdapter` into `freqhole-api-client` (identity getter parameterized); adapter unit tests with mocked midden + `navigator.locks`
174
+ - [x] repo singleton: `IndexedDBStorageAdapter("freqhole-automerge")` + broadcast + iroh adapters, leader-gated midden start (`src/services/automergeRepo.ts`, `src/services/p2pService.ts`)
175
+ - [x] `createDocStore` solid adapter with zod facade (`src/hooks/createDocStore.ts`, plus `createDocIndexQuery` + `docIndexService`)
176
+ - [x] fresh `musicPlaylistDB` v1 schema: `docIndex`, knocks, accessGrants, playbackPositions, lastPlayed, settings (no migration code - old stores dropped)
177
+
178
+ status: green. playlistz typecheck clean, 514 tests passing; freqhole-api-client 413 passing; spume 42 passing. note: `parsePlaylistDoc` plainifies raw docs before zod parsing - automerge proxies carry symbol keys (`_am_objectId`, `_am_datatype_`) that zod records reject.
179
+
180
+ ### phase 4: UI on docs (playlistz)
181
+
182
+ - [x] sidebar from `docIndex`; playlist view from doc store
183
+ - [x] mutations through `handle.change` in `usePlaylistManager`/`useSongState` (interfaces unchanged)
184
+ - [x] export/import + standalone zip updated to serialize from docs
185
+ - [x] full test suite green on the doc-backed data layer
186
+
187
+ status: green (playlistz 470 tests, typecheck clean). notes:
188
+
189
+ - `src/services/playlistDocService.ts` is the doc-backed CRUD layer; the old playlist/song idb CRUD is gone and `musicPlaylistDB` is at v1 with only local-state stores.
190
+ - view-shape adapters (`docToPlaylist`, `songEntryToSong`) keep the component-facing `Playlist`/`Song` types stable; image/audio bytes resolve on demand from the blob store by sha256.
191
+ - display filter settings (`bgFilter*`, `coverFilter*`) moved into the shared `PlaylistDoc` schema + `setMetadata` so they sync with the playlist.
192
+ - standalone idempotency: settings record `standalone:<playlistId>` -> `{ rev, docId }`; rev bump updates the existing doc instead of duplicating.
193
+
194
+ ### phase 5: p2p sharing (playlistz)
195
+
196
+ - [x] share panel: endpoint setup (name, public/knock), node id, lock status, connection summary, knock inbox (`src/components/SharePanel.tsx`, opened from the sidebar header)
197
+ - [x] open-share-link flow (`#share/` fragment + paste panel): find doc, add peer, index it (`sharingService.openShareLink` / `handleShareFragment`)
198
+ - [x] sharePolicy + peers recorded in doc on join; `reconnectKnownPeers` on leadership (skein's `registerAndReconnectPeers` pattern)
199
+ - [x] knock protocol on `freqhole-playlistz/1` (responder + requester) with knock inbox UI; grants can be scoped to specific docIds
200
+
201
+ status: green. `src/services/sharingService.ts` owns the whole phase 5 surface (settings, links, knock, responder loop). per-playlist "copy p2p share link" button in the playlist header. p2p resumes on boot only when an identity already exists (`resumeSharingIfEnabled`); first start stays a user action in the share panel. avatar upload UI deferred (`ShareSettings.avatarSha` is plumbed).
202
+
203
+ ### phase 6: blob transfer (playlistz)
204
+
205
+ - [x] audio/image fetch via iroh-blobs into the shared blob store with progress (`blobTransferService.fetchBlobForDoc`; audioService falls back to p2p fetch when a doc-backed song's blob is missing locally)
206
+ - [x] serving: import-on-demand of local blobs (`import_blob`, released after 10 min idle); ~30 min playback prefetch after each play (`prefetchUpcoming`); "save offline" button fetches all missing blobs for a playlist (`savePlaylistOffline`)
207
+
208
+ status: green. blob handshake rides the playlistz ALPN (`blob_request` -> `blob_ready { blake3, size }`), then `download_verified_streaming` over the iroh-blobs ALPN (handled rust-side in midden's accept loop). chunk callbacks copy via `chunk.slice()` since wasm buffers may be reused. unit tests: `sharingService.test.ts` (27) + `blobTransferService.test.ts` (16); e2e: `e2e/sharing.spec.ts` (4 + a two-context real-relay p2p test, skippable via `PLAYLISTZ_E2E_SKIP_P2P=1`).
209
+
210
+ ### phase 7: freqhole side (tomb)
211
+
212
+ - [ ] grimoire `freqhole-playlistz/1` responder (list/knock) + blob serving by blake3
213
+ - [ ] playlistz import flow: protocol fetch -> `freqholePlaylistToDoc` -> local doc (maybe defer this part)
214
+
215
+ ## later (deliberately deferred, designed for)
216
+
217
+ - ephemeral presence per playlist (`handle.broadcast`): listening-along indicators, live cursors over the song list.
218
+ - reliquary-style headless hub for playlistz (or reuse of reliquary itself): always-on doc + blob availability when browsers are closed.
219
+ - spume adopting doc-backed playlists via the shared schemas/adapter.
220
+ - skein/loam converging on the `freqhole-api-client` copy of `IrohNetworkAdapter`.
221
+ - waveform/lyrics/urls editing UI on top of the doc fields.
222
+
223
+ ## subagent dispatch notes
224
+
225
+ implementation work is dispatched to subagents per module. shared constraints for every dispatch:
226
+
227
+ - lowercase prose in comments/errors/logs; no emojis; no em dashes; npm.
228
+ - tomb work: freqhole-api-client is raw TS consumed directly - typecheck with `npm run typecheck`, test via `npm test` (tsx runner). spume changes must keep `npm test` green in `tomb/client/spume`.
229
+ - playlistz work: vitest via `npm test` (455+ passing, keep green); idb wrapper from `idb` package; fake-indexeddb in tests; coverage thresholds 70%.
230
+ - source references: skein adapter at `skein/loam/src/p2p/iroh-network-adapter.ts`, share strings at `skein/loam/src/p2p/share-string.ts`, doc facade at `skein/loam/src/widgets/widget-doc.ts`, spume blob store at `tomb/client/spume/src/music/services/storage/blobs.ts`, identity record shape `{ id: "p2p_identity", secret_key: Uint8Array(32), node_id, created_at }`.
231
+ - playlistz idb is greenfield: no deployed users, no migration code, schema changes are free. current types in `playlistz/src/types/playlist.ts` are reference only - the doc schema in `freqhole-api-client/playlistz` is now the source of truth.
232
+ - parallelizable units (disjoint files): storage extraction vs playlistz domain package vs adapter port. anything touching `freqhole-api-client/package.json` is done by the orchestrator first to avoid conflicts.
233
+ - each dispatch must state: exact files to create/modify, what NOT to touch (codegen dirs, unrelated services), test expectations, and the style constraints above.
@@ -0,0 +1,188 @@
1
+ # collaborative sharing plan
2
+
3
+ tracks design + implementation for per-playlist sharing modes, read-only subscribed mode, knock-with-message flow, collaborator access, and the fork/unsubscribe pattern.
4
+
5
+ ---
6
+
7
+ ## status
8
+
9
+ | area | status |
10
+ | ------------------------------------------ | ------ |
11
+ | data model: per-playlist sharing fields | [ ] |
12
+ | playlist read-only subscribed mode (ui) | [ ] |
13
+ | knock-with-message on search bar hit | [ ] |
14
+ | fork / unsubscribe from remote doc | [ ] |
15
+ | collaborator knock request + accept | [ ] |
16
+ | automerge sync policy (read-only vs rw) | [ ] |
17
+ | per-playlist sharing mode ui (share panel) | [ ] |
18
+ | conflict resolution ui | [ ] |
19
+
20
+ ---
21
+
22
+ ## data model
23
+
24
+ ### playlist doc schema additions
25
+
26
+ ```ts
27
+ // additions to the automerge playlist doc
28
+ interface PlaylistDoc {
29
+ // existing fields...
30
+
31
+ // sharing mode for this playlist. unset = not shared (private).
32
+ sharingMode?: "public" | "knock";
33
+
34
+ // node ids with write permission (collaborators).
35
+ // owner's node id is always implicitly a collaborator.
36
+ collaborators?: Record<string, true>;
37
+
38
+ // read-only subscribers: node ids allowed to receive updates but not push changes.
39
+ subscribers?: Record<string, true>;
40
+
41
+ // source info: set when this doc was received from a remote peer.
42
+ // null = owned by this user.
43
+ remoteSource?: {
44
+ nodeId: string; // peer who shared it
45
+ displayName?: string; // their display name at time of sync
46
+ addedAt: number; // timestamp when local copy was added
47
+ forked: boolean; // true once the user has forked to a local copy
48
+ };
49
+ }
50
+ ```
51
+
52
+ changes to `grimoire`-side playlist model need to mirror these fields so freqhole + playlistz stay interoperable.
53
+
54
+ ### docIndex additions
55
+
56
+ ```ts
57
+ interface DocIndexEntry {
58
+ // existing fields...
59
+ remoteNodeId?: string; // peer this was received from (if any)
60
+ remoteName?: string; // their display name
61
+ isForked?: boolean; // true if the user forked from the remote
62
+ }
63
+ ```
64
+
65
+ ---
66
+
67
+ ## knock-with-message flow
68
+
69
+ the search bar detects a share link and triggers `openShareLink`. before that resolves, if the peer's mode is "knock", a knock dialog should appear inline in the all-playlists panel.
70
+
71
+ ### ui flow
72
+
73
+ 1. user pastes share link into search bar
74
+ 2. `decodeShareToken` extracts `nodeId`
75
+ 3. app tries `queryPeerPlaylists(nodeId)` - if `knockRequired: true`:
76
+ - show a knock form in-panel:
77
+ ```
78
+ [name (pre-filled from local display name)]
79
+ [say who you are and mention something only the admin would know (but no passwords or secrets!)]
80
+ [send knock] [cancel]
81
+ ```
82
+ 4. `knockOnPeer(nodeId, { name, message })` - extend `KnockMessage` type to include `message?: string`
83
+ 5. status shows "knock sent - waiting for access..."
84
+ 6. when the owner accepts, the pending share link is opened automatically (the search bar retains the link until resolved)
85
+
86
+ ### service changes
87
+
88
+ - `knockOnPeer` in `sharingService.ts`: add optional `message` param, include it in the `knock` protocol message
89
+ - `KnockRecord` in `indexedDBService.ts`: already has `message` field - just needs to be surfaced in `knockOnPeer` call and knock accept ui
90
+
91
+ ---
92
+
93
+ ## per-playlist sharing mode
94
+
95
+ currently sharing mode (`public` / `knock`) is a global endpoint setting. it needs to move to per-playlist.
96
+
97
+ ### changes
98
+
99
+ - `sharingService.ts`: `getShareSettings()` keeps the global endpoint mode as a fallback default
100
+ - `PlaylistSharePanel.tsx`: "who can browse my playlistz?" buttons save to the current playlist doc's `sharingMode` field (not global settings)
101
+ - the protocol responder in `sharingService.ts` (`handlePlaylistzStream`) checks `doc.sharingMode` when deciding whether to require a knock for `list_playlists` and `blob_request` messages
102
+
103
+ ---
104
+
105
+ ## read-only subscribed mode
106
+
107
+ when a user adds a remote playlist (via share link or browse), it starts in **subscribed** mode:
108
+
109
+ - automerge sync is receive-only: local changes are not pushed back to the owner
110
+ - the ui reflects read-only state:
111
+ - title / description inputs are disabled
112
+ - drag-to-reorder is disabled
113
+ - add-song buttons are hidden
114
+ - edit panel shows two buttons instead of normal edit controls:
115
+ - **fork** - detaches from the remote doc, making a local editable copy
116
+ - **collaborate** - sends a collaborator knock request to the owner
117
+
118
+ ### ui sentinel
119
+
120
+ `selectedPlaylist().remoteSource && !selectedPlaylist().remoteSource.forked` → read-only mode
121
+
122
+ the main playlist component passes a `readOnly` prop down to song rows, title input, and the edit panel.
123
+
124
+ ### fork behavior
125
+
126
+ 1. user clicks "fork" in edit panel
127
+ 2. a new automerge doc is created from the current doc snapshot
128
+ 3. `remoteSource.forked = true` is written to the original doc's index entry
129
+ 4. the new doc is added to the index as a locally-owned playlist
130
+ 5. original subscribed doc is removed from the index (user no longer receives updates)
131
+ 6. new playlist is selected
132
+
133
+ the original automerge doc handle should be closed/unregistered so the repo stops syncing it.
134
+
135
+ ---
136
+
137
+ ## collaborator access
138
+
139
+ ### requesting collaboration
140
+
141
+ 1. user clicks "collaborate" in the edit panel of a subscribed playlist
142
+ 2. a knock is sent with `type: "collaborate_request"` (new knock type)
143
+ 3. ui shows "collaboration request sent" status
144
+
145
+ ### accepting collaboration
146
+
147
+ in the share panel's knock inbox:
148
+
149
+ - knock items with `type: "collaborate_request"` show a different label
150
+ - "accept (collaborator)" grants write access: adds the requester's node id to `doc.collaborators`
151
+ - once in `collaborators`, the automerge sync policy for that peer switches from read-only to bidirectional
152
+
153
+ ### automerge sync policy
154
+
155
+ the `sharePolicy` in `automergeRepo.ts` currently uses `peers` map in the doc. it needs to distinguish:
156
+
157
+ - `collaborators` map → full sync (push + receive)
158
+ - `subscribers` map → receive-only (announce doc to them, reject their changes)
159
+ - neither → no access
160
+
161
+ implementing receive-only at the automerge layer requires the `sharePolicy` to filter outbound sync messages per-peer. this may need a custom `SharePolicy` implementation.
162
+
163
+ ---
164
+
165
+ ## conflict resolution ui
166
+
167
+ when a subscribed playlist receives incoming changes that conflict with local edits (automerge detects divergence):
168
+
169
+ show a banner in the share panel (or edit panel header):
170
+
171
+ ```
172
+ incoming changes from [peer name]
173
+ [accept incoming — your changes will be overwritten]
174
+ [fork to new playlist — unsubscribe your copy]
175
+ ```
176
+
177
+ - **accept incoming**: call `doc.merge(remoteDoc)` explicitly, discarding local branch
178
+ - **fork to new playlist**: same as the fork flow above
179
+
180
+ detecting divergence: automerge doesn't expose conflict detection directly. a practical heuristic is comparing the doc's `updatedAt` timestamp against the last-known remote sync timestamp.
181
+
182
+ ---
183
+
184
+ ## open questions to resolve in session
185
+
186
+ - how to implement receive-only automerge sync at the repo level without forking automerge-repo? (may need a wrapper around the network adapter that drops outbound sync for non-collaborator docs)
187
+ - should per-playlist `sharingMode` be stored in the automerge doc itself (shared with peers) or only in the local docIndex? storing in the doc means the owner's mode change propagates to subscribers automatically.
188
+ - when a collaborator is added, do all existing subscribers also see the collaborator's changes? (probably yes, since the automerge doc is shared)