@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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# e2e testid + component refactor plan
|
|
2
|
+
|
|
3
|
+
this is the working reference for the ongoing refactor of e2e selectors and
|
|
4
|
+
component structure. add new entries here as testids are added. do not remove
|
|
5
|
+
entries; mark them done.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## naming conventions (summary)
|
|
10
|
+
|
|
11
|
+
| type | pattern | examples |
|
|
12
|
+
| -------------------------- | --------------------------- | --------------------------------------------------------------------------------------------- |
|
|
13
|
+
| app sentinel | `app-ready` | present once app shell has loaded |
|
|
14
|
+
| panels | `[name]-panel` | `all-playlists-panel`, `share-panel`, `edit-panel`, `song-edit-panel` |
|
|
15
|
+
| header icon buttons | `btn-[action]` | `btn-all-playlists`, `btn-edit-playlist`, `btn-share-playlist` |
|
|
16
|
+
| panel close button | `btn-close-panel` | single stable testid; only one panel open at a time |
|
|
17
|
+
| row-level buttons | `btn-[action]-song` | `btn-edit-song`, `btn-remove-song`, `btn-drag-song` |
|
|
18
|
+
| row-level playlist buttons | `btn-[action]-playlist-row` | `btn-edit-playlist-row`, `btn-share-playlist-row`, `btn-play-playlist-row` |
|
|
19
|
+
| empty state buttons | `btn-[action]` | `btn-new-playlist` |
|
|
20
|
+
| form inputs | `input-[field]` | `input-playlist-title`, `input-playlist-description`, `input-node-name`, `input-peer-node-id` |
|
|
21
|
+
| content display cells | `[component]-[field]` | `song-duration`, `playlist-song-count`, `playlist-total-time`, `row-song-count` |
|
|
22
|
+
| empty states | `empty-[component]` | `empty-songs`, `empty-playlists` |
|
|
23
|
+
| action buttons | `btn-[action]` | `btn-download-zip`, `btn-cache-offline`, `btn-p2p-save-offline` |
|
|
24
|
+
|
|
25
|
+
**no duplicate testids.** every `data-testid` value must be unique across the whole
|
|
26
|
+
component tree. if the same semantic thing appears in two places (e.g. song count
|
|
27
|
+
in the header vs. in a panel row), give each a distinct name. when no existing
|
|
28
|
+
pattern fits, use `btn-[verb]-[noun]` (kebab, no underscores).
|
|
29
|
+
|
|
30
|
+
**avoid `getByText` for assertions too.** content cells, counts, and state indicators
|
|
31
|
+
should get testids so tests assert on structure, not copy. `getByText` is acceptable
|
|
32
|
+
only when the test is explicitly verifying a user-visible string (e.g. a song title
|
|
33
|
+
that was just set in setup).
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## complete testid map
|
|
38
|
+
|
|
39
|
+
### `src/components/index.tsx`
|
|
40
|
+
|
|
41
|
+
| element | current test selector | testid | status |
|
|
42
|
+
| ------------------------------------ | -------------------------------------------- | ------------------ | ------ |
|
|
43
|
+
| `<h1 class="sr-only">playlistz</h1>` | `getByRole("heading", {name:"playlistz"})` | `app-ready` | [ ] |
|
|
44
|
+
| empty state "new playlist" button | `getByRole("button", {name:"new playlist"})` | `btn-new-playlist` | [ ] |
|
|
45
|
+
| "no playlistz yet" empty state | `getByText("no playlistz yet")` | `empty-playlists` | [ ] |
|
|
46
|
+
|
|
47
|
+
### `src/components/playlist/index.tsx` - header action buttons
|
|
48
|
+
|
|
49
|
+
| element | current selector | testid | status |
|
|
50
|
+
| -------------------------------- | ---------------------------------------------------------------- | ---------------------------- | ------ |
|
|
51
|
+
| hamburger (all playlists) button | `getByTitle("all playlistz")` | `btn-all-playlists` | [ ] |
|
|
52
|
+
| edit/close-edit toggle button | `getByTitle("edit playlist")` / `getByTitle("close edit panel")` | `btn-edit-playlist` | [ ] |
|
|
53
|
+
| share button | `getByTitle("share playlist")` | `btn-share-playlist` | [ ] |
|
|
54
|
+
| cache offline button | `getByTitle("download songz for offline use")` | `btn-cache-offline` | [ ] |
|
|
55
|
+
| p2p save offline button | `getByTitle("save offline (fetch from peerz)")` | `btn-p2p-save-offline` | [ ] |
|
|
56
|
+
| download zip button | `getByTitle("download playlist as zip")` | `btn-download-zip` | [ ] |
|
|
57
|
+
| song count span | `getByText("3 songz")` | `playlist-song-count` | [ ] |
|
|
58
|
+
| total time span | `getByText("0:03")` (fragile) | `playlist-total-time` | [ ] |
|
|
59
|
+
| playlist title input | `locator("input[placeholder='playlist title']")` | `input-playlist-title` | [ ] |
|
|
60
|
+
| playlist description input | `locator("input[placeholder='add description...']")` | `input-playlist-description` | [ ] |
|
|
61
|
+
| "no songz yet" empty state div | `getByText("no songz yet")` | `empty-songs` | [ ] |
|
|
62
|
+
|
|
63
|
+
### `src/components/playlist/PanelMiniHeader.tsx`
|
|
64
|
+
|
|
65
|
+
| element | current selector | testid | status |
|
|
66
|
+
| ------------ | ----------------------------------------------------------------------- | ----------------- | -------- |
|
|
67
|
+
| close button | `getByTitle("close share panel")` / `getByTitle("close all playlists")` | `btn-close-panel` | [x] done |
|
|
68
|
+
|
|
69
|
+
### `src/components/AllPlaylistsPanel.tsx`
|
|
70
|
+
|
|
71
|
+
| element | current selector | testid | status |
|
|
72
|
+
| ----------------------- | --------------------------------------------------- | ------------------------ | -------- |
|
|
73
|
+
| panel root div | `getByTestId("all-playlists-panel")` | `all-playlists-panel` | [x] done |
|
|
74
|
+
| "new playlist" button | `getByRole("button", {name:"new playlist"})` | `btn-new-playlist` | [ ] |
|
|
75
|
+
| play row button | `getByTitle("play ...")` | `btn-play-playlist-row` | [ ] |
|
|
76
|
+
| edit row button | `getByTitle("edit playlist")` (in panel) | `btn-edit-playlist-row` | [ ] |
|
|
77
|
+
| share row button | `getByTitle("share playlist")` (in panel) | `btn-share-playlist-row` | [ ] |
|
|
78
|
+
| download zip row button | `getByTitle("download playlist as zip")` (in panel) | `btn-download-zip-row` | [ ] |
|
|
79
|
+
| song count cell per row | `getByText("3 songz")` | `row-song-count` | [ ] |
|
|
80
|
+
|
|
81
|
+
### `src/components/PlaylistSharePanel.tsx`
|
|
82
|
+
|
|
83
|
+
| element | current selector | testid | status |
|
|
84
|
+
| -------------------------------- | ------------------------------------------------------ | --------------------- | ------ |
|
|
85
|
+
| panel root | none | `share-panel` | [ ] |
|
|
86
|
+
| mode toggle: knock first | `getByRole("button", {name:"knock first"})` | `btn-mode-knock` | [ ] |
|
|
87
|
+
| mode toggle: public | `getByRole("button", {name:"anyone (public)"})` | `btn-mode-public` | [ ] |
|
|
88
|
+
| endpoint toggle button | `getByTitle("enable endpoint")` / `"disable endpoint"` | `btn-toggle-endpoint` | [ ] |
|
|
89
|
+
| copy share link button | `getByRole("button", {name:"copy share link"})` | `btn-copy-share-link` | [ ] |
|
|
90
|
+
| p2p share link input (read-only) | none | `input-share-link` | [ ] |
|
|
91
|
+
| node name input | `locator("input[placeholder='anonymous']")` | `input-node-name` | [ ] |
|
|
92
|
+
| browse peer node id input | none | `input-peer-node-id` | [ ] |
|
|
93
|
+
| open/browse peer button | `getByRole("button", {name:"open", exact:true})` | `btn-browse-peer` | [ ] |
|
|
94
|
+
| knock request button | `getByTitle("ask this peer for access")` | `btn-knock-peer` | [ ] |
|
|
95
|
+
| "enable p2p sharing" toggle | `getByText("enable p2p sharing")` | `btn-enable-sharing` | [ ] |
|
|
96
|
+
| online/offline status indicator | `getByText("online")` | `sharing-status` | [ ] |
|
|
97
|
+
| "no pending knockz" empty state | `getByText("no pending knockz")` | `empty-knock-inbox` | [ ] |
|
|
98
|
+
| "invalid share link" error | `getByText("invalid share link")` | `share-link-error` | [ ] |
|
|
99
|
+
| "playlist added!" success msg | `getByText("playlist added!")` | `share-success` | [ ] |
|
|
100
|
+
| knock inbox section | `getByText("knock inbox")` | `knock-inbox` | [ ] |
|
|
101
|
+
|
|
102
|
+
### `src/components/PlaylistEditPanel.tsx`
|
|
103
|
+
|
|
104
|
+
| element | current selector | testid | status |
|
|
105
|
+
| -------------------------------- | ------------------------------------------ | ------------------ | ------ |
|
|
106
|
+
| panel root | none | `edit-panel` | [ ] |
|
|
107
|
+
| playlist cover background button | `getByTitle("set the page background...")` | `btn-set-bg-cover` | [ ] |
|
|
108
|
+
|
|
109
|
+
### `src/components/SongRow.tsx`
|
|
110
|
+
|
|
111
|
+
| element | current selector | testid | status |
|
|
112
|
+
| ------------------ | ------------------------------------ | ----------------- | -------- |
|
|
113
|
+
| duration cell | `[data-testid='song-duration']` | `song-duration` | [x] done |
|
|
114
|
+
| edit song button | `getByTitle("edit song")` | `btn-edit-song` | [ ] |
|
|
115
|
+
| remove song button | `getByTitle("remove from playlist")` | `btn-remove-song` | [ ] |
|
|
116
|
+
| drag handle | `getByTitle("drag to reorder")` | `btn-drag-song` | [ ] |
|
|
117
|
+
|
|
118
|
+
### `src/components/SongEditPanel.tsx`
|
|
119
|
+
|
|
120
|
+
| element | current selector | testid | status |
|
|
121
|
+
| ---------- | ---------------- | ----------------- | ------ |
|
|
122
|
+
| panel root | none | `song-edit-panel` | [ ] |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## e2e helpers to update (`e2e/helpers.ts`)
|
|
127
|
+
|
|
128
|
+
| function | current | target |
|
|
129
|
+
| ------------------------- | ---------------------------------------------------- | -------------------------------------------- |
|
|
130
|
+
| `waitForApp` | `getByRole("heading", {name:"playlistz"}).waitFor()` | `getByTestId("app-ready").waitFor()` |
|
|
131
|
+
| `createPlaylistViaUI` | `getByRole("button", {name:"new playlist"}).first()` | `getByTestId("btn-new-playlist")` |
|
|
132
|
+
| `createPlaylistViaUI` | `getByTitle("all playlistz").first().click()` | `getByTestId("btn-all-playlists").click()` |
|
|
133
|
+
| `createPlaylistViaUI` | `getByTitle("edit playlist").first().waitFor()` | `getByTestId("btn-edit-playlist").waitFor()` |
|
|
134
|
+
| `openSharePanel` | `getByTitle("share playlist").first().click()` | `getByTestId("btn-share-playlist").click()` |
|
|
135
|
+
| `openSharePanel` | `getByTestId("btn-close-panel").waitFor()` | already correct |
|
|
136
|
+
| `addSongs` last-song wait | `getByText("song-0N")` | keep - asserting content is correct |
|
|
137
|
+
|
|
138
|
+
## spec file selector audit
|
|
139
|
+
|
|
140
|
+
every `getByTitle(...)`, `getByRole("button", {name:...})`, `getByText(...)` used
|
|
141
|
+
to _click_ or _wait_ on an element should become `getByTestId(...)`. `getByText`
|
|
142
|
+
may remain only when the assertion is verifying that specific content is visible
|
|
143
|
+
(e.g. a playlist or song name the test itself just created).
|
|
144
|
+
|
|
145
|
+
key replacements across all specs:
|
|
146
|
+
|
|
147
|
+
| current pattern | replacement |
|
|
148
|
+
| ------------------------------------------------------------------ | --------------------------------------------------- |
|
|
149
|
+
| `getByTitle("edit playlist")` | `getByTestId("btn-edit-playlist")` |
|
|
150
|
+
| `getByTitle("share playlist")` | `getByTestId("btn-share-playlist")` |
|
|
151
|
+
| `getByTitle("all playlistz")` | `getByTestId("btn-all-playlists")` |
|
|
152
|
+
| `getByRole("button", {name:"new playlist"})` | `getByTestId("btn-new-playlist")` |
|
|
153
|
+
| `getByRole("button", {name:"knock first"})` | `getByTestId("btn-mode-knock")` |
|
|
154
|
+
| `getByRole("button", {name:"anyone (public)"})` | `getByTestId("btn-mode-public")` |
|
|
155
|
+
| `getByRole("button", {name:"copy share link"})` | `getByTestId("btn-copy-share-link")` |
|
|
156
|
+
| `getByRole("button", {name:"open", exact:true})` | `getByTestId("btn-browse-peer")` |
|
|
157
|
+
| `getByTitle("enable endpoint")` / `getByTitle("disable endpoint")` | `getByTestId("btn-toggle-endpoint")` |
|
|
158
|
+
| `getByText("enable p2p sharing")` (click/wait) | `getByTestId("btn-enable-sharing")` |
|
|
159
|
+
| `getByText("online")` (wait) | `getByTestId("sharing-status")` |
|
|
160
|
+
| `getByText("no pending knockz")` | `getByTestId("empty-knock-inbox")` |
|
|
161
|
+
| `getByText("invalid share link")` | `getByTestId("share-link-error")` |
|
|
162
|
+
| `getByText("playlist added!")` | `getByTestId("share-success")` |
|
|
163
|
+
| `getByText("no songz yet")` | `getByTestId("empty-songs")` |
|
|
164
|
+
| `getByText("3 songz")` in header | `getByTestId("playlist-song-count")` |
|
|
165
|
+
| `getByText("3 songz")` in all-playlists row | `getByTestId("row-song-count")` |
|
|
166
|
+
| `locator("input[placeholder='playlist title']")` | `getByTestId("input-playlist-title")` |
|
|
167
|
+
| `locator("input[placeholder='anonymous']")` | `getByTestId("input-node-name")` |
|
|
168
|
+
| `getByTestId("btn-edit-playlist-row")` (in panel) | scope to `getByTestId("all-playlists-panel")` first |
|
|
169
|
+
| `getByTestId("btn-share-playlist-row")` (in panel) | scope to `getByTestId("all-playlists-panel")` first |
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## playwright config changes (`config/playwright.config.ts`)
|
|
174
|
+
|
|
175
|
+
- add `expect: { timeout: 5000 }` to the `use` block (makes current `{ timeout: 5000 }` inline overrides redundant - remove them)
|
|
176
|
+
- the global `actionTimeout: 10_000` stays; it already covers most click timeouts
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## component split plan for `playlist/index.tsx` (1073 lines)
|
|
181
|
+
|
|
182
|
+
this is the biggest structural win. the file contains several logical sub-components
|
|
183
|
+
that should be extracted. suggested order (each extraction is independently mergeable):
|
|
184
|
+
|
|
185
|
+
1. **`PlaylistHeaderActions`** - the row of header icon buttons (hamburger, edit,
|
|
186
|
+
share, cache, download). currently `playerControls()` inner function (~lines 470-800).
|
|
187
|
+
extract to `src/components/playlist/PlaylistHeaderActions.tsx`.
|
|
188
|
+
|
|
189
|
+
2. **`PlaylistSongCount`** - the song count + total time display spans. small but
|
|
190
|
+
gives a stable testid home. could fold into PlaylistHeaderActions.
|
|
191
|
+
|
|
192
|
+
3. **`PlaylistCoverImage`** - the desktop cover image column. currently `coverImage()`
|
|
193
|
+
inner function (~lines 805-855). extract to `src/components/playlist/PlaylistCoverImage.tsx`.
|
|
194
|
+
|
|
195
|
+
4. **`PlaylistSongList`** - the rows container + empty state + all the Show/For
|
|
196
|
+
logic for song rows. currently ~lines 1000-1073. extract to
|
|
197
|
+
`src/components/playlist/PlaylistSongList.tsx`.
|
|
198
|
+
|
|
199
|
+
5. **`PlaylistContainer` itself** - once the above are extracted it shrinks to
|
|
200
|
+
just panel switching logic + header + scroll container + keyboard handler.
|
|
201
|
+
|
|
202
|
+
extraction order matters: start with leaf components (no internal dependencies),
|
|
203
|
+
work inward. run `npm run typecheck` after each extraction.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## `waitForTimeout` audit
|
|
208
|
+
|
|
209
|
+
these are the known "sleep" calls. replace with element waits where a clear signal exists:
|
|
210
|
+
|
|
211
|
+
| file | line | current | replacement |
|
|
212
|
+
| ------------------------ | ------- | -------------------------------------- | ----------------------------------------------------------------------------- |
|
|
213
|
+
| `sharing-access.spec.ts` | ~75 | `waitForTimeout(300)` after mode click | wait for button to gain `border-magenta` class |
|
|
214
|
+
| `sharing-access.spec.ts` | ~113 | `waitForTimeout(500)` after mode click | same |
|
|
215
|
+
| `sharing.spec.ts` | ~52 | `waitForTimeout(300)` after mode click | same |
|
|
216
|
+
| `all-playlists.spec.ts` | many | `waitForTimeout(300)` after title blur | wait for `input-playlist-title` value to settle (hard signal) - leave for now |
|
|
217
|
+
| others | various | `waitForTimeout(300)` after blur | same - leave until we have a better signal |
|
|
218
|
+
|
|
219
|
+
items marked "leave for now" are waiting on automerge write propagation where
|
|
220
|
+
there is no DOM signal. do not remove them without adding a proper wait.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## implementation order
|
|
225
|
+
|
|
226
|
+
1. [x] update `copilot-instructions.md` with conventions
|
|
227
|
+
2. [x] write this plan doc
|
|
228
|
+
3. [ ] add all source testids (source agent)
|
|
229
|
+
4. [ ] update `config/playwright.config.ts` (add `expect.timeout`)
|
|
230
|
+
5. [ ] update `e2e/helpers.ts` (test agent)
|
|
231
|
+
6. [ ] update all spec files (test agent)
|
|
232
|
+
7. [ ] `npm run typecheck` + `npm run test:e2e -- --grep-invert "@p2p"`
|
|
233
|
+
8. [ ] extract `PlaylistHeaderActions` from `playlist/index.tsx`
|
|
234
|
+
9. [ ] extract remaining sub-components incrementally
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# iroh p2p networking plan
|
|
2
|
+
|
|
3
|
+
> NOTE: partially superseded by [AUTOMERGE_P2P_PLAN.md](AUTOMERGE_P2P_PLAN.md) - playlist sync now uses automerge docs instead of the hand-rolled wire protocol and spume idb store mirroring. the packaging, identity interop, web locks, blob store, and knock sections here remain authoritative and are referenced from the new plan.
|
|
4
|
+
|
|
5
|
+
introduce iroh p2p networking into playlistz, interoperable with freqhole (tomb/). playlistz consumes `midden` (rust->wasm iroh client) and `freqhole-api-client` (generated TS client + zod schemas) via `file:` package refs. a new `freqhole-playlistz/1` ALPN lets playlistz apps share playlists with each other and with freqhole servers, including public sharing and knock access requests. share links use the same base64url token format as spume.
|
|
6
|
+
|
|
7
|
+
progress is tracked with checkboxes in each phase. this doc holds decisions and technical detail; open questions live in conversation, decisions get folded back in here.
|
|
8
|
+
|
|
9
|
+
## reference map
|
|
10
|
+
|
|
11
|
+
key code in tomb/ this plan builds on:
|
|
12
|
+
|
|
13
|
+
| concern | location |
|
|
14
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | ------------ |
|
|
15
|
+
| midden wasm bindings | `tomb/client/midden/src/lib.rs`, build via `tomb/client/midden/Makefile` (wasm-pack, bundler target), output `tomb/client/midden/pkg/` |
|
|
16
|
+
| midden JS API | `MiddenNode` (create/create_from_key/create_with_alpns, accept, open_bi, blob import/download), `BiStream` (write_message/read_message 4-byte BE length framing, write_line/read_line ndjson, read_to_end/write_raw_and_finish) |
|
|
17
|
+
| ALPN registry | hardcoded in `create_with_secret_key()` in midden lib.rs: `freqhole/1`, `iroh/automerge-repo/1`, `freqhole-friendz/1`, `freqhole-admin/1`, `freqhole-events/1`, `freqhole-radio/1`, iroh-blobs |
|
|
18
|
+
| api client | `tomb/client-codegen/freqhole-api-client/` - raw TS source, exports `FreqholeClient`, `HttpTransport`, `WasmTransport`, `AdminClient`, generated `codegen/schema.ts` + `codegen/routes.ts` |
|
|
19
|
+
| freqhole playlist schema | `tomb/grimoire/src/music/entities/playlists/models.rs` + `PlaylistSchema` in schema.ts |
|
|
20
|
+
| freqhole song schema | `tomb/grimoire/src/music/entities/songs/models.rs` + `SongSchema` |
|
|
21
|
+
| image metadata | `ImageMetadata { blob_id, is_primary (0/1), blob_type: "original" | "thumbnail" | "waveform" | "preview" }` |
|
|
22
|
+
| entity urls | `EntityUrl { id?, name?, url }` on playlists and songs |
|
|
23
|
+
| spume identity persistence | IDB db `freqhole_app` v8, store `app_state` (keyPath `id`), record key `p2p_identity`: `{ id, secret_key: Uint8Array(32), node_id: string, created_at: number }` |
|
|
24
|
+
| spume opfs blobs | opfs `/blobs/{blobId}` where blobId = sha256 hex of content |
|
|
25
|
+
| share token format | `tomb/client/spume/src/utils/permalink.ts` + `components/share/buildSharePayload.ts` - `SharePayloadV1`, canonical JSON (sorted keys) -> base64url no padding |
|
|
26
|
+
| knock model | `KnockRequest { id, node_id, username, message, status: pending | accepted | rejected, created_at, processed_at?, processed_by? }`, public routes `POST /api/knock`, `GET /api/knock/status` |
|
|
27
|
+
| spume midden init | `tomb/client/spume/src/app/api/client.ts` - getP2PIdentity -> create_from_key or create+save, then blob server |
|
|
28
|
+
| vite wasm config | `tomb/client/spume/vite.config.ts` - `vite-plugin-wasm` + `vite-plugin-top-level-await`, `target: "esnext"`, no COOP/COEP needed (midden is single-threaded, no SharedArrayBuffer) |
|
|
29
|
+
|
|
30
|
+
key playlistz internals affected:
|
|
31
|
+
|
|
32
|
+
| concern | location |
|
|
33
|
+
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
34
|
+
| data model | `src/types/playlist.ts` (Playlist, Song) |
|
|
35
|
+
| idb | `src/services/indexedDBService.ts` - db `musicPlaylistDB` v6, stores: playlists, songs, playbackPositions, lastPlayed, settings; live queries with fields whitelist + BroadcastChannel `musicPlaylistDB-changes` |
|
|
36
|
+
| audio storage | inline `song.audioData: ArrayBuffer` in songs store, `song.sha` = sha256 of audio bytes |
|
|
37
|
+
| image storage | inline `imageData`/`thumbnailData` ArrayBuffers + `imageType` mime on playlist/song records |
|
|
38
|
+
| export format | `src/utils/standaloneTemplates.ts` zod schemas, `window.__PLAYLISTZ__`, `playlistz.js` |
|
|
39
|
+
| edit panel flyout | `src/components/playlist/index.tsx` + `useSongState` signals (`editingPlaylist`, `editingSong`) |
|
|
40
|
+
| build | `config/vite.config.ts` (dev/app), `build-component.js` (standalone IIFE web component + cli) |
|
|
41
|
+
|
|
42
|
+
## architecture decisions
|
|
43
|
+
|
|
44
|
+
### guiding principle: one shared storage/p2p layer
|
|
45
|
+
|
|
46
|
+
every piece of browser storage or p2p plumbing built here should end up in exactly one place - `freqhole-api-client` (already shared) or its new `storage` subpath - with spume and playlistz as thin consumers. accept slower implementation now for one maintained codepath later. concretely the rule is: before writing storage/transfer code in playlistz, check spume for an equivalent; if one exists, extract it to the shared package (and migrate spume to the extracted version) rather than reimplementing. app-specific orchestration (reactivity, UI state, each app's own record shapes) stays in each app.
|
|
47
|
+
|
|
48
|
+
### packages
|
|
49
|
+
|
|
50
|
+
- `package.json` gains `"midden": "file:../tomb/client/midden/pkg"` and `"freqhole-api-client": "file:../tomb/client-codegen/freqhole-api-client"`.
|
|
51
|
+
- freqhole-api-client is raw TS source (no build step), bundles fine with vite. zod versions are compatible (playlistz ^4.4.3, client requires ^4.3.5).
|
|
52
|
+
- midden requires `vite-plugin-wasm` + `vite-plugin-top-level-await` in `config/vite.config.ts`. no COOP/COEP headers needed.
|
|
53
|
+
- single-file standalone: we will attempt to bundle midden's wasm INTO `freqhole-playlistz.js`. approach: build midden with wasm-pack `web` target (its `init(input)` accepts raw bytes), base64-inline `midden_bg.wasm` in `build-component.js` (esbuild `binary`/define step), decode + `init(bytes)` at runtime. p2p stays behind a lazy dynamic import so the app boots fine if wasm init fails (e.g. exotic browsers); if the inline approach proves unworkable the fallback is hosted-only p2p with the standalone build failing soft.
|
|
54
|
+
- shared storage namespace: new browser-storage code that both spume and playlistz can use lives in `freqhole-api-client` under a dedicated subpath export, e.g. `freqhole-api-client/storage` (`src/storage/` - idb db/store constants + open helpers, blob store (opfs + cache fallback), identity records, rolling blob cache). strictly browser-agnostic of either app's UI; spume migrates to it opportunistically. keeps tomb code unaware of playlistz while sharing the exact schemas.
|
|
55
|
+
|
|
56
|
+
### ALPN
|
|
57
|
+
|
|
58
|
+
- new ALPN string: `freqhole-playlistz/1`.
|
|
59
|
+
- registered two ways:
|
|
60
|
+
1. playlistz (JS side) uses `MiddenNode.create_with_alpns(keyBytes, ["freqhole-playlistz/1"])`.
|
|
61
|
+
2. tomb side: add `const PLAYLISTZ_ALPN: &[u8] = b"freqhole-playlistz/1";` to midden lib.rs alpn list and grimoire/freqhole server accept loop so freqhole nodes can answer playlistz protocol requests.
|
|
62
|
+
- playlistz runs the JS `accept()` loop (not `start_blob_server()`); midden's `accept()` handles iroh-blobs ALPN internally in rust and surfaces all other ALPNs to JS as `BiStream`, so inbound blob serving still works alongside the playlistz protocol handler.
|
|
63
|
+
|
|
64
|
+
### identity interop with spume
|
|
65
|
+
|
|
66
|
+
- identity record shape is spume's exactly: `{ id: "p2p_identity", secret_key: Uint8Array(32), node_id, created_at }`.
|
|
67
|
+
- playlistz NEVER creates or upgrades `freqhole_app` - it has zero version coupling to spume's db. detection without accidental creation: `indexedDB.databases()` where available; fallback is `indexedDB.open(name)` with an `onupgradeneeded` handler that aborts the versionchange transaction (upgradeneeded only fires for a missing db when opening without a version, and aborting cancels creation).
|
|
68
|
+
- startup resolution order:
|
|
69
|
+
1. `freqhole_app` exists with an `app_state` store -> read `p2p_identity` from it; if the store exists but has no identity, write ours there (plain writes to an existing store need no version bump).
|
|
70
|
+
2. otherwise -> identity lives in playlistz's own `musicPlaylistDB` `settings` store under key `p2p_identity` (same record shape).
|
|
71
|
+
3. no identity anywhere -> `MiddenNode.create()`, persist per the same order (spume db if present, else local).
|
|
72
|
+
- result: on spume's domain playlistz transparently shares spume's node identity; standalone domains self-contain; a stale playlistz deploy can never block a spume db upgrade.
|
|
73
|
+
- playlistz-specific p2p settings (profile name, avatar, public/knock policy) live in playlistz's own `settings` store, not in `freqhole_app`.
|
|
74
|
+
|
|
75
|
+
### single live node per origin (web locks)
|
|
76
|
+
|
|
77
|
+
two tabs (or spume + playlistz on one domain) initializing midden from the same secret key would put two live endpoints with the same node id on the network. guard with the Web Locks API:
|
|
78
|
+
|
|
79
|
+
- exclusive lock name `freqhole-iroh-node` (origin-scoped, so spume and playlistz contend for the same lock when co-hosted).
|
|
80
|
+
- leader tab: acquires the lock (held until tab close), inits the midden node, runs the accept loop, serves blobs.
|
|
81
|
+
- non-leader tabs: detect the held lock (`navigator.locks.request(..., { ifAvailable: true })` returning null), stay passive, and queue a normal lock request so leadership fails over automatically when the leader tab closes (init node only once the lock is granted).
|
|
82
|
+
- share panel surfaces lock state: "sharing active in this tab" (leader), "sharing is active in another tab" (passive, with the queued takeover noted), or "not started".
|
|
83
|
+
- the lock helper lives in `freqhole-api-client/storage` so spume can adopt the same guard (it currently has this bug too).
|
|
84
|
+
|
|
85
|
+
### spume storage alignment
|
|
86
|
+
|
|
87
|
+
long-term goal: playlistz and spume on the same domain share playlists, songs, and images through identical idb/opfs schemas. tomb code never references playlistz; the shared shapes live in `freqhole-api-client/storage`. concretely, now:
|
|
88
|
+
|
|
89
|
+
- iroh identity: spume's record shape and (when present) spume's db, via the read-only access rules above.
|
|
90
|
+
- image blob bytes: spume's exact model - opfs `/blobs/{blobId}` (blobId = sha256 hex of content) with Cache API fallback (`freqhole-blobs` cache, key `https://blob.local/{blobId}`) for browsers without opfs. blob metadata in spume's exact `freqhole_blobs` idb db (v1, store `blobs`, keyPath `blob_id`, record `{ blob_id, storage_type: "opfs"|"cache", storage_path, mime_type, file_size, created_at }`). this replaces the earlier idea of a playlistz-local `imageBlobs` store.
|
|
91
|
+
- naming: new playlistz fields use shapes that adapt 1:1 to spume's `freqhole_music` records (`is_primary` boolean<->0/1, blob ids as sha256) so a later convergence to spume's `freqhole_music` stores (playlists keyPath `playlist_id`, songs keyPath `id` + `by_sha256` index, composite `playlist_songs` store) is a data migration, not a redesign.
|
|
92
|
+
- audio bytes: p2p-fetched audio goes straight into the shared blob store (opfs) instead of inline `audioData` - see data model below. on a shared domain this means spume and playlistz read the same `/blobs/{sha256}` file rather than duplicating the song. migrating EXISTING inline `audioData` to opfs is the deferred part, not the write path.
|
|
93
|
+
- deferred (tracked in "later" below): migrating legacy inline `audioData` to the blob store, adopting spume's `freqhole_music` stores outright, and the rolling ~30min download cache (spume's `freqhole_cache_metadata` blobCache) for remote playback.
|
|
94
|
+
|
|
95
|
+
extraction inventory (what moves from spume into `freqhole-api-client/storage`):
|
|
96
|
+
|
|
97
|
+
| spume source | shareability | action |
|
|
98
|
+
| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
99
|
+
| `src/music/services/storage/blobs.ts` (opfs + Cache API fallback, `freqhole_blobs` metadata db, object-url session cache) | fully generic, only depends on `idb` | extract verbatim; spume re-exports from shared package |
|
|
100
|
+
| `src/music/services/cache/blobCache.ts` + `cacheNames.ts` + `inProgressTracking.ts` | generic idb/Cache/eviction core wrapped in spume reactive + remoteManager glue | split: extract the core, leave spume's reactive wrapper consuming it (deferred with the rolling cache) |
|
|
101
|
+
| `freqhole-api-client` `WasmTransport` (`fetchBlobWithProgress`, `download_verified_streaming`, `import_blob`/`release_blob`, `uploadViaIrohBlobs`) | already shared | playlistz consumes as-is |
|
|
102
|
+
| `src/music/services/storage/blobResolver.ts`, `audioAccess.ts` | spume-specific orchestration (freqhole_music songs, remotes, sync queue) | stays in spume; playlistz writes its own thin equivalent over the shared layers |
|
|
103
|
+
|
|
104
|
+
### data model changes (freqhole schema fit)
|
|
105
|
+
|
|
106
|
+
new types in `src/types/playlist.ts`, mirroring freqhole's wire shapes but with JS-friendly booleans:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
type BlobKind = "original" | "thumbnail" | "waveform" | "preview";
|
|
110
|
+
|
|
111
|
+
interface ImageRef {
|
|
112
|
+
blobId: string; // sha256 hex of image bytes (matches spume opfs naming)
|
|
113
|
+
isPrimary: boolean; // converts to/from freqhole is_primary 0/1
|
|
114
|
+
blobType: BlobKind;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface EntityUrl {
|
|
118
|
+
id?: string;
|
|
119
|
+
name?: string;
|
|
120
|
+
url: string;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- `Playlist` gains: `images?: ImageRef[]`, `urls?: EntityUrl[]`, `isPublic?: boolean`.
|
|
125
|
+
- `Song` gains: `images?: ImageRef[]`, `urls?: EntityUrl[]`, `lyrics?: string`, `blake3?: string` (for iroh-blobs transfer; computed via midden `hash_blake3` or at import), `audioBlobId?: string` (sha256, audio bytes in the shared blob store).
|
|
126
|
+
- image bytes move to the shared blob store (opfs `/blobs/{sha256}` + `freqhole_blobs` metadata db, see spume storage alignment). multiple covers per playlist/song with one primary.
|
|
127
|
+
- legacy `imageData`/`thumbnailData`/`imageType` fields remain readable; DB_VERSION 7 migration converts each existing record's inline image into two blob store entries (original + thumbnail) and an `images` array with `isPrimary: true`. inline fields are then deleted from records.
|
|
128
|
+
- audio dual path: songs have EITHER inline `audioData` (existing local imports, unchanged) OR `audioBlobId` pointing at opfs (all p2p-fetched audio). a single accessor in audio loading checks `audioBlobId` first, falls back to `audioData`; everything downstream sees a Blob/object url either way. local file imports keep writing `audioData` for now; flipping them to the blob store (plus migrating existing records) is the deferred opfs move, which then becomes a backend swap behind the same accessor. `sha` remains the content identity, `blake3` sits next to it for p2p.
|
|
129
|
+
- live query fields whitelists in `createPlaylistsQuery` / `createPlaylistSongsQuery` MUST gain: `images`, `urls`, `isPublic`, `lyrics`, `blake3`, `audioBlobId` (lesson learned: missing whitelist fields are silently stripped on every re-emit).
|
|
130
|
+
- export schemas in `standaloneTemplates.ts` and import logic in `standaloneService.ts` / `playlistDownloadService.ts` gain the new fields (multiple images written to `data/` in zips, primary first).
|
|
131
|
+
- UI for the new metadata (lyrics, urls, non-primary image management, waveform rendering) is deferred; phase 1 only keeps the existing single-cover editing experience working on top of the new model. no client-side waveform generation - waveform blobs only arrive from freqhole and are stored for later rendering.
|
|
132
|
+
|
|
133
|
+
### wire protocol: freqhole-playlistz/1
|
|
134
|
+
|
|
135
|
+
message framing: `BiStream.write_message` / `read_message` (4-byte BE u32 length prefix) carrying utf-8 JSON. every request/response is zod-validated. one request per stream open; responses may be multi-message for listings followed by clean EOF.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// request envelope
|
|
139
|
+
{ v: 1, type: string, ...payload }
|
|
140
|
+
|
|
141
|
+
// types (requester -> responder):
|
|
142
|
+
{ v: 1, type: "hello" }
|
|
143
|
+
-> { v: 1, type: "hello", profile: { name, nodeId, avatarBlobId?, public: boolean }, playlistCount }
|
|
144
|
+
{ v: 1, type: "list_playlists" }
|
|
145
|
+
-> { v: 1, type: "playlists", playlists: PlaylistSummary[] } // requires access if not public
|
|
146
|
+
{ v: 1, type: "get_playlist", playlistId }
|
|
147
|
+
-> { v: 1, type: "playlist", playlist: WirePlaylist } // full metadata incl songs, image refs, blake3s
|
|
148
|
+
{ v: 1, type: "get_blob", blobId } // small images only; audio goes via iroh-blobs
|
|
149
|
+
-> { v: 1, type: "blob", mimeType, dataBase64 }
|
|
150
|
+
{ v: 1, type: "knock", name, message }
|
|
151
|
+
-> { v: 1, type: "knock_status", status: "pending" | "accepted" | "rejected" }
|
|
152
|
+
{ v: 1, type: "knock_status", knockId }
|
|
153
|
+
-> { v: 1, type: "knock_status", status }
|
|
154
|
+
{ v: 1, type: "error", code, message } // any failure
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `WirePlaylist` maps playlistz fields to freqhole-compatible naming (snake_case, `is_public` 0/1 free: we keep JSON booleans since this protocol is playlistz-defined; the freqhole server side adapter does the conversion).
|
|
158
|
+
- audio transfer: requester gets `blake3` hashes from `get_playlist`, then uses midden `download_verified_streaming(peer_addr, blake3, size, on_chunk, on_progress)` over the iroh-blobs ALPN. responder pre-imports audio buffers into midden's blob store (`import_blob`) on share activation and tracks active blobs.
|
|
159
|
+
- access control on the responder: if profile is public, all reads allowed. otherwise reads require the requester node_id to be in the local `accessGrants` (populated by accepting knocks). knock requests always allowed.
|
|
160
|
+
|
|
161
|
+
### knock model (local, borrowed from freqhole)
|
|
162
|
+
|
|
163
|
+
new `knocks` IDB store (keyPath `id`):
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
interface KnockRecord {
|
|
167
|
+
id: string; // uuid
|
|
168
|
+
nodeId: string; // requester iroh node id (inbound) or responder node id (outbound)
|
|
169
|
+
direction: "inbound" | "outbound";
|
|
170
|
+
name: string;
|
|
171
|
+
message: string;
|
|
172
|
+
status: "pending" | "accepted" | "rejected";
|
|
173
|
+
createdAt: number;
|
|
174
|
+
processedAt?: number;
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
plus an `accessGrants` store (keyPath `nodeId`): `{ nodeId, name, grantedAt }`. accepting an inbound knock writes a grant; the share panel lists pending knocks with accept/reject. outbound knocks are re-checked on startup (mirrors spume's pendingKnockChecker) via `knock_status` requests.
|
|
179
|
+
|
|
180
|
+
### share links
|
|
181
|
+
|
|
182
|
+
same token scheme as spume so links are cross-compatible: canonical JSON (sorted keys, no whitespace) -> base64url without padding, payload `SharePayloadV1`:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
{ v: 1, k: "playlist", i: <playlistId>, s: { n: <nodeId hex> }, t?: <title> }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- url shape: `<app origin>/#?share=<token>`.
|
|
189
|
+
- on load, playlistz parses `location.hash`, decodes the token, opens a "remote playlist" view: hello -> knock if needed -> get_playlist -> stream songs on demand (with local caching into the normal stores, marked with a `sourceNodeId` so synced copies are distinguishable).
|
|
190
|
+
- decoding tolerates unknown fields and `s.h` (http origin) for freqhole-server-sourced shares; fetching via http transport when `s.h` is present and `s.n` is absent.
|
|
191
|
+
|
|
192
|
+
### reading freqhole playlists (playlistz as client of freqhole)
|
|
193
|
+
|
|
194
|
+
- uses `freqhole-api-client`'s `WasmTransport` with the shared midden node against a freqhole server peer addr - same path spume uses, e.g. `client.music.listPlaylists`, `query_playlist_songs`, blob downloads via `download_verified_by_id`/blake3.
|
|
195
|
+
- knock to a freqhole server uses the existing public knock route via `proxy_request` (`POST /api/knock`), not the playlistz ALPN.
|
|
196
|
+
- a freqhole-sourced playlist maps into playlistz's model through an adapter (`images[].is_primary` 0/1 -> boolean, `media_blob_id` + `blake3` -> song download, `lyrics`, `urls` direct).
|
|
197
|
+
|
|
198
|
+
### share panel UI
|
|
199
|
+
|
|
200
|
+
- new share icon button in the playlist header action row, after the zip download button (`src/components/playlist/index.tsx`).
|
|
201
|
+
- new `sharingPlaylist` boolean signal in `useSongState` following the `editingPlaylist` pattern; `isEditing()` includes it so the row flyout animation runs; `SharePanel` renders in the same slot as `PlaylistEditPanel`.
|
|
202
|
+
- panel contents:
|
|
203
|
+
- first-run endpoint setup: profile name, avatar image upload, policy toggle (all playlists public vs knock required). persisted in settings store; activates the midden node + accept loop.
|
|
204
|
+
- node status: node id (copyable), connectivity.
|
|
205
|
+
- per-playlist share controls: generate/copy share link, toggle playlist public override.
|
|
206
|
+
- inbound knocks list with accept/reject.
|
|
207
|
+
- remotes: known peers (granted/outbound-knocked), with last-seen.
|
|
208
|
+
|
|
209
|
+
### open share link UI
|
|
210
|
+
|
|
211
|
+
- global `p2pConfigured` state (derived from settings store: endpoint setup completed) exposed via context, reactive on the settings store.
|
|
212
|
+
- when `p2pConfigured` is true, the sidebar's `+ new playlist` row splits into two side-by-side buttons: the existing primary `+ new playlist`, and a non-primary `open share link` button.
|
|
213
|
+
- `open share link` opens a panel (share panel chrome, different content): a text input for a pasted link, status/validation text below it, and a primary `open` action.
|
|
214
|
+
- token extraction is forgiving: accept a bare token or any URL from any domain - scan the pasted string for the base64url payload chunk (e.g. after `#?share=`, `?share=`, `/o/`, or the longest base64url-decodable JSON segment), decode, validate against SharePayloadV1. validation feedback shows kind/title/node id preview before opening.
|
|
215
|
+
- on confirm, runs the same remote playlist flow as a `#?share=` page load.
|
|
216
|
+
|
|
217
|
+
## implementation phases
|
|
218
|
+
|
|
219
|
+
### phase 1: data model + storage groundwork (no p2p yet)
|
|
220
|
+
|
|
221
|
+
- [ ] `freqhole-api-client/storage` subpath in tomb: extract spume's idb constants/types (`freqhole_app`, `freqhole_blobs`, p2p identity record) and opfs blob store (write/read/delete `/blobs/{sha256}`, cache api fallback) into `src/storage/`; keep spume compiling (it can adopt the shared module later or immediately, whichever is cheaper)
|
|
222
|
+
- [ ] add `ImageRef`, `EntityUrl`, `BlobKind` types; extend `Playlist`/`Song` interfaces (`images`, `urls`, `isPublic`, `lyrics`, `blake3`)
|
|
223
|
+
- [ ] playlistz consumes the shared blob store for image bytes; DB_VERSION 7: `knocks` + `accessGrants` stores; migration converting inline image fields to blob store entries + `images` arrays
|
|
224
|
+
- [ ] update live query fields whitelists (playlists + songs queries)
|
|
225
|
+
- [ ] imageService: resolve `ImageRef[]` -> object urls (primary first) via blob store; keep `getImageUrlForContext` API + current single-cover edit UX working
|
|
226
|
+
- [ ] export/import: standaloneTemplates schemas + playlistDownloadService + standaloneService support multiple images, lyrics, urls (backward compatible with existing zips)
|
|
227
|
+
- [ ] tests for migration, blob store crud (opfs mocked), query field passthrough, export/import roundtrip
|
|
228
|
+
|
|
229
|
+
### phase 2: package + build wiring
|
|
230
|
+
|
|
231
|
+
- [ ] add `file:` deps (midden pkg, freqhole-api-client); npm install; verify midden pkg builds from tomb (`make build` in tomb/client/midden)
|
|
232
|
+
- [ ] add `vite-plugin-wasm` + `vite-plugin-top-level-await` to `config/vite.config.ts` and vitest config as needed
|
|
233
|
+
- [ ] gate all midden imports behind a lazy `import()` in a single `src/services/p2p/middenNode.ts` module
|
|
234
|
+
- [ ] single-file attempt: midden `web`-target build (add a `build-web` target to the existing `tomb/client/midden/Makefile` emitting `pkg-web/` via `wasm-pack build -t web -d pkg-web`), base64-inline `midden_bg.wasm` into `freqhole-playlistz.js` via `build-component.js`, runtime decode + `init(bytes)`; verify standalone zip still works on `file://` (with and without p2p available)
|
|
235
|
+
- [ ] smoke test: create node in dev app, log node id
|
|
236
|
+
|
|
237
|
+
### phase 3: p2p core service
|
|
238
|
+
|
|
239
|
+
- [ ] `freqhole-api-client/storage`: identity resolution (detect `freqhole_app` without creating it, read/write `p2p_identity`, local-settings fallback) + `freqhole-iroh-node` web lock helper (leader election, lock state callback)
|
|
240
|
+
- [ ] `src/services/p2p/identity.ts`: playlistz wiring of the shared identity resolution (local fallback = `musicPlaylistDB` settings key `p2p_identity`)
|
|
241
|
+
- [ ] `src/services/p2p/middenNode.ts`: leader-gated singleton init (acquire lock -> `create_from_key`/`create` + `create_with_alpns` for `freqhole-playlistz/1`), node id accessor, ready + lock-state signals
|
|
242
|
+
- [ ] `src/services/p2p/acceptLoop.ts`: `accept()` loop dispatching by `stream.alpn()`; playlistz protocol handler entry
|
|
243
|
+
- [ ] tomb side: add `PLAYLISTZ_ALPN` to midden lib.rs registered list (and bump midden version, rebuild pkg)
|
|
244
|
+
- [ ] tests with a mocked midden module + mocked `navigator.locks`
|
|
245
|
+
|
|
246
|
+
### phase 4: playlistz wire protocol
|
|
247
|
+
|
|
248
|
+
- [ ] `src/services/p2p/protocol.ts`: zod schemas for all message types (versioned envelope), encode/decode helpers over `write_message`/`read_message`
|
|
249
|
+
- [ ] responder handlers: hello, list_playlists, get_playlist, get_blob, knock, knock_status with access checks (public/grants)
|
|
250
|
+
- [ ] requester client: typed functions mirroring the handlers
|
|
251
|
+
- [ ] blob serving: on share activation import audio buffers into midden blob store; release on deactivation; track imported hashes
|
|
252
|
+
- [ ] audio fetch path: `download_verified_streaming` -> shared blob store (`storeBlob`, opfs) + set `song.audioBlobId`; audio accessor resolves `audioBlobId` || `audioData` (reuse streamingAudioService progress UX)
|
|
253
|
+
- [ ] protocol unit tests (handler logic against fake streams)
|
|
254
|
+
|
|
255
|
+
### phase 5: knocks + access grants
|
|
256
|
+
|
|
257
|
+
- [ ] knock crud in indexedDBService (knocks, accessGrants stores) with live queries
|
|
258
|
+
- [ ] inbound knock handling in responder (dedupe by nodeId, pending record)
|
|
259
|
+
- [ ] outbound knock + startup status recheck
|
|
260
|
+
- [ ] accept/reject actions writing grants
|
|
261
|
+
- [ ] tests
|
|
262
|
+
|
|
263
|
+
### phase 6: share panel UI
|
|
264
|
+
|
|
265
|
+
- [ ] `sharingPlaylist` signal in useSongState + flyout wiring in playlist container; share icon button after zip download button
|
|
266
|
+
- [ ] `src/components/SharePanel.tsx`: endpoint setup form (name, avatar via blob store, public/knock policy), node id display, lock/leadership status ("sharing active in this tab" / "active in another tab" / "not started"), per-playlist share link generation, knock inbox, remotes list
|
|
267
|
+
- [ ] settings persistence (settings store keys: `p2pProfile`, `p2pPolicy`, `p2pEnabled`) + reactive `p2pConfigured` global state in context
|
|
268
|
+
- [ ] sidebar: when `p2pConfigured`, split `+ new playlist` into two side-by-side buttons (primary `+ new playlist`, non-primary `open share link`)
|
|
269
|
+
- [ ] `open share link` panel: paste input, forgiving base64url token extraction from any url/string, validation preview (kind/title/node id), primary open action
|
|
270
|
+
- [ ] component tests
|
|
271
|
+
|
|
272
|
+
### phase 7: share links + remote playlist view
|
|
273
|
+
|
|
274
|
+
- [ ] `src/utils/shareToken.ts`: SharePayloadV1-compatible encode/decode (canonical json, base64url)
|
|
275
|
+
- [ ] hash route parsing on startup (`#?share=`)
|
|
276
|
+
- [ ] remote playlist flow: hello -> (knock) -> get_playlist -> render read-only remote playlist with per-song fetch/stream + local save, `sourceNodeId` tagging
|
|
277
|
+
- [ ] tests for token roundtrip + spume token compatibility fixtures
|
|
278
|
+
|
|
279
|
+
### phase 8: freqhole server interop
|
|
280
|
+
|
|
281
|
+
- [ ] adapter `src/services/p2p/freqholeAdapter.ts`: freqhole `PlaylistSchema`/`SongQueryResult` -> playlistz model (0/1 -> boolean, media_blob_id/blake3 mapping)
|
|
282
|
+
- [ ] `WasmTransport` client usage for browsing/syncing freqhole playlists the user has access to
|
|
283
|
+
- [ ] knock to freqhole servers via public knock route over `proxy_request`
|
|
284
|
+
- [ ] tomb side: freqhole server answers `freqhole-playlistz/1` (grimoire handler translating protocol requests to playlist queries)
|
|
285
|
+
- [ ] end-to-end test against a local freqhole server
|
|
286
|
+
|
|
287
|
+
### phase 9: polish + docs
|
|
288
|
+
|
|
289
|
+
- [ ] error surfaces (connection failures, denied access, stale grants)
|
|
290
|
+
- [ ] perf: lazy wasm init only when share panel opened or share link present
|
|
291
|
+
- [ ] docs/INDEX.md entry + user-facing share flow writeup
|
|
292
|
+
- [ ] full test pass, coverage thresholds maintained
|
|
293
|
+
|
|
294
|
+
## later (deliberately deferred, designed for)
|
|
295
|
+
|
|
296
|
+
these are out of scope for the phases above but the decisions above keep their cost low:
|
|
297
|
+
|
|
298
|
+
- **metadata UI**: rendering/editing lyrics, entity urls, multiple non-primary images, waveform display. the data model, storage, wire protocol, and export formats all carry these fields from phase 1; only components are missing.
|
|
299
|
+
- **rolling download cache + download-everything**: adopt spume's blobCache pattern (`freqhole_cache_metadata` db, ~30min rolling window of remote audio, plus a one-shot save-offline-everything action) as a shared module in `freqhole-api-client/storage`. playlistz's `streamingAudioService` + `offlineService.cacheAudioFile` are the integration points; spume migrates to the shared module too.
|
|
300
|
+
- **full idb convergence**: move playlistz playlists/songs onto spume's `freqhole_music` store layout (playlists keyPath `playlist_id` with `by_source_remote_id`/`by_last_synced_at` indices, songs `by_sha256`, composite `playlist_songs`) so same-domain apps share one library. field shapes already adapt 1:1; this becomes a data migration plus swapping store constants for the shared ones.
|
|
301
|
+
- **legacy audio bytes into the shared blob model**: p2p audio already lands in opfs via `audioBlobId`; this item is flipping local file imports to do the same and migrating existing inline `audioData` records (background task: hash, `storeBlob`, set `audioBlobId`, delete `audioData`). pure backend swap behind the existing accessor.
|
|
302
|
+
- **freqhole-generated playlistz zips (takeout)**: freqhole server dispenses zip bundles in playlistz's export format (playlistz.js + data/). the export schema gains an optional `sourceNodeId` on the playlist header so bundles carry their origin node id and can knock/sync back to the source. needs a grimoire takeout job + the standalone import path honoring `sourceNodeId`.
|