@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- 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)
|