@freqhole/playlistz 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,695 @@
1
+ # playlistz roadmap
2
+
3
+ active work is at the top, completed reference is at the bottom.
4
+
5
+ status: `[ ]` not started, `[~]` in progress, `[x]` done.
6
+
7
+ ---
8
+
9
+ ## direction
10
+
11
+ ### modularization strategy
12
+
13
+ the long-term aim is a clean package layering. currently a lot of logic lives
14
+ in `playlistz/src/services/` that either duplicates `tomb/client/spume/`, or
15
+ belongs in shared packages. rather than "moving things to tomb/", the goal is
16
+ extracting reusable pieces into packages that both apps can depend on.
17
+
18
+ current package layering:
19
+
20
+ ```
21
+ freqhole-api-client (framework-agnostic: storage, p2p wire, schemas, share links)
22
+ |
23
+ @freqhole/solid (SolidJS wrappers: reactive signals over IDB, playlist hooks)
24
+ |
25
+ playlistz/ + spume/ (app-specific UI and state)
26
+ ```
27
+
28
+ `freqhole-api-client` already exports: blob storage (IDB+OPFS/Cache), p2p
29
+ identity, iroh automerge adapter, zod schemas for `PlaylistDoc` + `SongEntry`,
30
+ knock/blob protocol messages, share link encode/decode.
31
+
32
+ concrete next extractions:
33
+
34
+ - **p2p node lifecycle** - `startP2P`, `stopP2P`, `getNode`, `getIdentity`,
35
+ accept loop, ALPN routing from `p2pService.ts` into `freqhole-api-client`
36
+ - **blob transfer protocol** - `fetchBlobFromPeer` + `serveBlobRequest` BiStream
37
+ message exchange from `blobTransferService.ts` into `freqhole-api-client`
38
+ - **new `@freqhole/solid` package** - SolidJS-specific wrappers that shouldn't
39
+ live in `freqhole-api-client` (no framework deps there): reactive signals over
40
+ IDB, playlist hooks (`usePlaylistManager` shape), drag-and-drop primitives,
41
+ media session bridge. both playlistz and spume could depend on this.
42
+ - **shared e2e utilities** - `makeWav()`, `dropFiles()`, `waitForApp()` are
43
+ already reasonably generic; worth a `@freqhole/e2e-utils` package that playlistz
44
+ and any future tomb e2e suites share. `e2e/helpers/` is the extraction target.
45
+ - **audio layer** - spume has `playerState`, `backends/`, `mediaSessionBridge`,
46
+ `playbackOrchestrator`; playlistz has `audioService.ts` + `streamingAudioService.ts`.
47
+ media session plumbing belongs in `freqhole-api-client/src/audio/mediaSession.ts`
48
+ (no SolidJS deps); the backend abstraction is a candidate for `@freqhole/solid`.
49
+
50
+ for **`tomb/playlistz/`** specifically: it's an earlier copy of
51
+ `freqhole/playlistz/src/services/` that has quietly diverged (lacks p2pService,
52
+ blobTransferService, etc.). the goal is not to pick a "winner" and copy files
53
+ over, but to have both apps depend on the shared packages above so there's nothing
54
+ left to diverge. audit first, extract shared pieces, then the `tomb/playlistz/`
55
+ copy just goes away naturally.
56
+
57
+ for **two-browser `@p2p` e2e tests**: these need iroh relay + midden WASM
58
+ which are built and managed in `tomb/`. the tests themselves should be shared
59
+ or runnable from both sides; see the e2e infrastructure section.
60
+
61
+ ---
62
+
63
+ ## code conventions
64
+
65
+ ### underscore-prefix internal/dev exports
66
+
67
+ there are currently two distinct reasons for underscore-prefix names; they
68
+ should be treated differently:
69
+
70
+ - **`_internal*`** (intended: module-private) - used inside a file but exported
71
+ by accident, or exported for a test that imports directly. these should NOT be
72
+ exported at all. move to a private helper or use vitest module mocking instead.
73
+
74
+ - **`_dev*`** (intentional: dev-hook injection points) - exported specifically
75
+ for use by the corresponding `.dev.ts` file. the rule is:
76
+ - `_dev*` exports in `service.ts` are consumed only by `service.dev.ts`
77
+ - `service.dev.ts` registers them as `window.__*` hooks
78
+ - nothing else should import `_dev*` directly
79
+
80
+ current state: `_devSetFetchOverride`, `_devEvictBlob`, `_devSetBlobFetchTimeout`,
81
+ `_devFetchBlobBySha` are in `blobTransferService.ts`; `_devSeekTo`,
82
+ `_devTriggerTrackEnd`, `_devTriggerAudioError` are in `audioService.ts`. all
83
+ consumed only by their `.dev.ts` counterparts. pattern is working.
84
+
85
+ - [ ] lint rule to enforce: `_dev*` symbols from service files may only be
86
+ imported by `*.dev.ts` files. use `eslint-plugin-import` `no-restricted-imports`
87
+ or a custom rule.
88
+ - [ ] audit for accidentally-exported `_internal*` helpers (not `_dev*`) - these
89
+ should lose the export keyword or be inlined.
90
+
91
+ ### export hygiene
92
+
93
+ services currently leak too much through their public API surface. guiding rules:
94
+
95
+ - a service's public exports are what components and hooks need - no more
96
+ - `export *` is only allowed in shim/barrel files (`e2e/helpers.ts`,
97
+ `e2e/helpers/index.ts`, `src/components/index.tsx`)
98
+ - types used only inside a service file should not be exported
99
+ - when a type is needed in two places, export it from the canonical module
100
+ (usually `src/types/playlist.ts`) not from the service
101
+
102
+ - [ ] audit each service file: list exports, mark which are actually imported
103
+ elsewhere. remove or unexport any that aren't.
104
+ - [ ] `eslint-plugin-import` `no-unused-modules` rule to surface dead exports
105
+
106
+ ### SolidJS reactivity patterns
107
+
108
+ we have a recurring class of bugs where reactive values are read in non-reactive
109
+ contexts. canonical mistakes and their fixes:
110
+
111
+ | mistake | symptom | fix |
112
+ | --------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------- |
113
+ | destructure `props` | component never updates | use `props.foo` directly, or `splitProps` |
114
+ | `onMount` for derived state | state stale after async resolution | `createEffect` tracks signal deps and re-runs |
115
+ | read signal in event handler, no effect | stale closure over initial value | read inside the handler (closures over signals are fine - they call the getter each time) |
116
+ | `createMemo` with side effects | double-execution, stale deps | memos are for derived values only; side effects go in `createEffect` |
117
+ | spread props onto DOM element | unknown prop warnings + reactivity loss | use `splitProps` to separate known props from rest |
118
+
119
+ concrete example fixed this session: `PlaylistEditPanel` used `onMount` to set
120
+ cover image URL from `props.playlist.imageFilePath`. after page reload,
121
+ `docToPlaylistAsync` resolves `imageFilePath` asynchronously after the panel
122
+ mounts. the fix was `createEffect` which re-runs whenever `props.playlist.*`
123
+ changes. `onMount` is correct only when you genuinely want "run once on mount
124
+ with whatever the initial prop value is."
125
+
126
+ - [ ] add `eslint-plugin-solid` to the project
127
+ - `solid/no-destructure` - catches prop destructuring
128
+ - `solid/reactivity` - warns on reactive values used outside reactive scope
129
+ - `solid/event-handlers` - naming conventions
130
+ - evaluate `solid/prefer-show` - probably off for now (subjective)
131
+ - [ ] fix any violations that `eslint-plugin-solid` surfaces after adding it
132
+
133
+ ### .dev.ts pattern (mock isolation)
134
+
135
+ services that need dev/test hooks follow this split:
136
+
137
+ ```
138
+ src/services/fooService.ts - production code. exports _dev* only as injection points
139
+ src/services/fooService.dev.ts - mock implementations + registerFooDevHooks()
140
+ src/dev-hooks.ts - thin glue: imports and calls all register*DevHooks()
141
+ ```
142
+
143
+ `dev-hooks.ts` is dynamically imported DEV-only:
144
+ `void import("../dev-hooks.js")` inside a `if (import.meta.env.DEV)` guard
145
+ in `src/components/index.tsx`. never present in production builds.
146
+
147
+ the `window.__*` hooks declared in `src/types/global.d.ts` are the e2e-facing
148
+ API; `e2e/helpers/hooks.ts` provides typed wrappers over them.
149
+
150
+ ### e2e test tagging + scripts
151
+
152
+ tests are tagged by transport dependency:
153
+
154
+ | tag | meaning | script |
155
+ | ------- | ----------------------------------------------------- | --------------- |
156
+ | (none) | real UI + real IDB/OPFS, no transport mock | `test:e2e:real` |
157
+ | `@mock` | uses `window.__mockBlobFetch` - transport substituted | `test:e2e:mock` |
158
+ | `@p2p` | real two-browser iroh transfer | `test:e2e:p2p` |
159
+
160
+ `@mock` tests are fast (run in-process, no relay), cover state-machine edge cases.
161
+ `@p2p` tests are slow (relay dependent, 30-120s), prove the wire protocol works.
162
+ both are needed; they test different things.
163
+
164
+ time-acceleration hooks (`seekTo`, `triggerTrackEnd`, `triggerAudioError`) are
165
+ NOT considered mock transport - don't tag tests that only use these as `@mock`.
166
+
167
+ ---
168
+
169
+ ---
170
+
171
+ ## pick up here
172
+
173
+ last session (2026-06-12): 80/80 tests passing. mock infrastructure complete.
174
+ ROADMAP restructured. `PlaylistEditPanel` cover-image `onMount`→`createEffect` bug fixed.
175
+
176
+ **next immediate tasks (in rough priority order):**
177
+
178
+ 1. **`eslint-plugin-solid`** - add the plugin, fix violations. fast win, improves
179
+ all future SolidJS work. see "code conventions: SolidJS reactivity patterns". update .github/copilot-instructions.md and try to make it a regular point to review and address lint issues (which might mean tuning lint rules config)
180
+
181
+ 2. **initial blob prefetch on share link open** - wire `prefetchUpcoming()` call
182
+ into `sharingService.ts` after `handleShareFragment()` resolves. add one `@mock`
183
+ e2e test. see "active work: initial blob prefetch on share link open".
184
+
185
+ 3. **song row progress fraction** - `blobTransferService` already fires `onProgress`.
186
+ wire into `SongRow` as a thin fill or `data-download-fraction` attr + test.
187
+ see "active work: song row download progress fraction".
188
+
189
+ 4. **export audit** - run `eslint-plugin-import no-unused-modules`, trim dead exports.
190
+ low risk, improves readability before modularization work starts. also add ts-prune and madge circular dep check (see spume npm scripts for example) npm script and figure out a strategy, like lint runs, to review and address issues occasionally.
191
+
192
+ ---
193
+
194
+ ## active work
195
+
196
+ ### p2p transport: UX gaps
197
+
198
+ #### initial blob prefetch on share link open
199
+
200
+ when a guest opens a share link, they should hear music as soon as possible.
201
+ the knock + automerge sync flow works, but blob transfers don't start until
202
+ playback is triggered.
203
+
204
+ - [ ] after `handleShareFragment()` resolves and the playlist doc is confirmed,
205
+ call `prefetchUpcoming(playlist, firstSongId, totalPlaylistDuration)` to
206
+ kick off blob transfers immediately
207
+ - [ ] the trigger point is in `sharingService.ts` - wire into the existing
208
+ `pendingShareDocId` watch or a new `onPlaylistDocReady` callback
209
+ - [ ] e2e test: synthetic share fragment pointing at a local doc, assert
210
+ `data-download-state` starts then clears for the first song (mock transport)
211
+ - **modularization note**: the prefetch call is already in `blobTransferService.ts`;
212
+ only the trigger point in `sharingService.ts` needs wiring
213
+
214
+ #### player loading state on share link auto-play
215
+
216
+ when a guest opens a share link and the first blob isn't local yet, the player
217
+ should communicate why playback hasn't started rather than appearing broken.
218
+
219
+ - [x] `AudioPlayer.tsx` shows spinner + `aria-busy="true"` when `isFetchingBlob()`
220
+ is true - this covers the blob-fetch case already
221
+ - [ ] the blob-fetch spinner and the audio-buffering spinner are currently the same
222
+ visual - consider a distinct "waiting for p2p" indicator (tooltip, color, or
223
+ icon variant) so the user knows it's a network wait, not a decode issue
224
+ - [ ] if the initial blob fetch errors, show the error state from `blobDownloadStates`
225
+ on the play button rather than resetting to idle
226
+ - **note**: initial prefetch (above) landing first makes this properly testable
227
+
228
+ #### player stall detection
229
+
230
+ distinct from the blob-fetch spinner (pre-play). this covers post-fetch stalls
231
+ where the blob arrived but the audio element can't buffer fast enough.
232
+
233
+ - [ ] if `audio.readyState < HAVE_FUTURE_DATA` persists for > N seconds after
234
+ `play()` is called, surface a "stalling" indicator on the player button
235
+ - [ ] distinct from the normal buffering spinner - ideally a different icon or
236
+ tooltip explaining the stall
237
+ - [ ] `__triggerStall(durationMs)` dev hook for unit testing; skip for e2e
238
+
239
+ ---
240
+
241
+ ### p2p transport: testing + fixtures
242
+
243
+ #### longer fixtures for realistic p2p testing
244
+
245
+ current committed fixtures top out at ~10s. longer tracks are needed to test
246
+ prefetch windows and rendering under sustained transfer load.
247
+
248
+ - [ ] add `long-drone-5min.mp3` to `e2e/fixtures/generate.mjs` - use ffmpeg
249
+ with `-t 300`, low bitrate mono to keep file size manageable
250
+ - [ ] add a fixture with a specific total duration suitable for testing the
251
+ "first 30 minutes" prefetch window boundary
252
+ - [ ] once longer fixtures exist: assert that `prefetchUpcoming` stops scheduling
253
+ new blobs once the 30-min budget is exhausted
254
+
255
+ #### remaining mocked transport test scenarios
256
+
257
+ `e2e/p2p-states.spec.ts` covers timeout, pending, retry, prefetch triggering,
258
+ cancellation, and seek recalculation. gaps:
259
+
260
+ - [ ] **progress chunks**: `__mockBlobFetch({ type: "progress", chunks: 5,
261
+ msPerChunk: 100 })` - assert `data-download-state` is present during fetch
262
+ and clears on completion. (progress fraction UI is a separate item below;
263
+ this test only needs state transitions.)
264
+ - [ ] once a progress fraction display exists in `SongRow`: assert the fraction
265
+ increments during a `{ type: "progress" }` mock fetch
266
+
267
+ #### real two-browser @p2p tests
268
+
269
+ mock tests cover state-machine edge cases fast; real tests prove the transport
270
+ actually works end-to-end. both are needed.
271
+
272
+ currently in `e2e/sharing.spec.ts` + `e2e/sharing-access.spec.ts`:
273
+
274
+ - host creates playlist, shares link, guest opens - playlist visible
275
+ - knock/public mode toggle (single-browser; no actual blob transfer yet)
276
+
277
+ new tests to add:
278
+
279
+ - [ ] **blob transfer smoke** (tagged `@p2p`, timeout 120s): host drops
280
+ `tone-440hz-2s.wav`, guest opens share link, wait for `data-download-state`
281
+ to clear, assert song plays. the canonical "does p2p actually work" test.
282
+ - [ ] **knock blocks then unblocks** (`@p2p`): host in knock mode, guest opens
283
+ share link, assert `data-download-state="error"` (knock_required), host
284
+ approves, guest retries, blob arrives
285
+ - [ ] **host offline mid-transfer** (`@p2p`): guest starts fetching, host closes
286
+ browser, assert guest shows `data-download-state="error"` gracefully
287
+ - [ ] **prefetch before playback** (`@p2p`): host drops 3 short songs, guest
288
+ waits for all 3 `data-download-state` attrs to clear (prefetch window),
289
+ then plays - assert no stall
290
+
291
+ **infrastructure note**: once blob transfer protocol is in `freqhole-api-client`,
292
+ the e2e harness for `@p2p` tests can be shared: `tomb/` supplies the iroh relay
293
+ and midden WASM build; playlistz e2e imports from a shared `@freqhole/e2e-utils`
294
+ package. mocked `@mock` tests stay in `freqhole/playlistz/e2e/` and need no tomb
295
+ infrastructure.
296
+
297
+ ---
298
+
299
+ ### share + access control UX
300
+
301
+ #### per-playlist visibility modes
302
+
303
+ current: one global mode (public / knock) applies to all playlists.
304
+
305
+ - [ ] `PlaylistDoc` gains a `visibility: "public" | "knock" | "private"` field
306
+ - [ ] `sharePolicy` in `automergeRepo.ts` checks per-doc visibility before
307
+ announcing to a peer
308
+ - [ ] `list_playlists` and `blob_request` handlers check per-doc visibility
309
+ instead of global settings mode
310
+ - [ ] ui: visibility toggle in the share panel, per-playlist
311
+ - [ ] migration: docs without `visibility` default to global mode setting
312
+
313
+ #### knock flow UX + two-browser coverage
314
+
315
+ - [ ] knock inbox: show pending knock with peer node id + optional display name
316
+ - [ ] approval ui: one-click accept / deny with "remember this peer" option
317
+ - [ ] `aria-live` region on knock inbox for screen readers
318
+ - [ ] two-browser `@p2p` test: knock mode, guest knocks, host approves, guest streams
319
+ - [ ] two-browser `@p2p` test: public mode, guest streams immediately
320
+
321
+ #### invite codes
322
+
323
+ - [ ] short-lived signed tokens: `${docId}:${nonce}:${expiry}` (HMAC) that
324
+ pre-authorise a peer without a knock
325
+ - [ ] reusable invite links: same shape, no nonce expiry, revocable by host
326
+ - [ ] share panel: "create invite link" button copies a `#invite/...` fragment
327
+ - [ ] invite link open flow: validates token, adds peer to ACL, auto-selects playlist
328
+ - [ ] revocation: stored in doc's `invites` map, host can invalidate
329
+
330
+ #### collaborative playlists
331
+
332
+ - [ ] per-playlist `collaborative: boolean` flag in `PlaylistDoc`
333
+ - [ ] when true: all peers with access can mutate the doc; "collaborative" badge;
334
+ edit panel shows `addedBy: string` per song
335
+ - [ ] when false: originating peer only; "fork this playlist" button in share panel
336
+ for non-owned playlists
337
+
338
+ ---
339
+
340
+ ### ui + tooltips
341
+
342
+ no toast UI in this app. prefer `title` attributes and inline tooltip-style
343
+ affordances. kobalte may be worth adding for accessible popover/tooltip
344
+ primitives if the complexity warrants it.
345
+
346
+ #### zip download: p2p fetch state
347
+
348
+ - [ ] if not all blobs are locally cached when download-zip is pressed: show a
349
+ spinner on the button and set `title="waiting on p2p fetch..."` while
350
+ missing blobs are fetched
351
+ - [ ] if pressed again while waiting: show a confirmation tooltip (not modal) -
352
+ "not all songs are downloaded yet. download anyway?" with confirm/cancel
353
+
354
+ #### song row download progress fraction
355
+
356
+ - [ ] `blobTransferService` already fires `onProgress(fraction)` callbacks.
357
+ wire this into `SongRow` as a fill on the duration cell (or a thin bar)
358
+ so large-file downloads show incremental progress
359
+ - [ ] `data-download-fraction` attribute for e2e assertions
360
+
361
+ #### other polish
362
+
363
+ - [ ] all-playlists panel: search input when `playlists().length > 10`;
364
+ client-side filter; "new playlist" row stays sticky
365
+ - [ ] `PanelMiniHeader`: inline title editing and hover cover image
366
+ - [ ] share panel "open a share link" input: after resolving, show correct
367
+ player state (ties into initial prefetch above)
368
+
369
+ ---
370
+
371
+ ### modularization: concrete next steps
372
+
373
+ each item is independently dispatchable. see "direction" above for the overall
374
+ package layering goal.
375
+
376
+ #### extract node lifecycle to freqhole-api-client
377
+
378
+ scope: `tomb/client-codegen/freqhole-api-client/`. no UI changes.
379
+
380
+ `playlistz/src/services/p2pService.ts` handles node startup, identity, ALPN
381
+ registration, and leader election. this logic is app-agnostic.
382
+
383
+ - [ ] move `startP2P`, `stopP2P`, `getNode`, `getIdentity`, `waitForNode`,
384
+ `onLeadershipChange`, `hasExistingIdentity` into
385
+ `freqhole-api-client/src/p2p/node.ts`
386
+ - [ ] `playlistz/p2pService.ts` becomes a thin wrapper that supplies the accept
387
+ loop callback and app-specific ALPN handlers
388
+ - [ ] spume can adopt the same shared node lifecycle
389
+ - [ ] update `freqhole-api-client` package exports + version
390
+
391
+ #### extract blob transfer protocol to freqhole-api-client
392
+
393
+ scope: `tomb/client-codegen/freqhole-api-client/` + `playlistz/src/services/`.
394
+
395
+ the BiStream wire protocol lives in `blobTransferService.ts`; the IDB/storage
396
+ layer is already in `freqhole-api-client`.
397
+
398
+ - [ ] extract `fetchBlobFromPeer` + `serveBlobRequest` (BiStream message exchange)
399
+ into `freqhole-api-client/src/playlistz/blobProtocol.ts`
400
+ - [ ] `blobTransferService.ts` retains app-level logic only: prefetch scheduling,
401
+ state signals (`blobDownloadStates`), inflight dedup, timeout, retry
402
+ - [ ] spume's blob transfer can adopt the shared protocol functions
403
+ - [ ] all existing `@mock` and `@p2p` e2e tests must continue to pass
404
+
405
+ #### create @freqhole/solid package
406
+
407
+ scope: new package in `tomb/` monorepo. no app-code changes initially.
408
+
409
+ a SolidJS-specific layer over `freqhole-api-client`. exports reactive wrappers
410
+ and hooks that both playlistz and spume can use.
411
+
412
+ - [ ] scaffold `tomb/packages/solid/` (or alongside `freqhole-api-client`)
413
+ with its own `package.json`, `tsconfig.json`, `vite.config.ts`
414
+ - [ ] move `usePlaylistManager`-shaped hook into the package (playlistz version
415
+ as reference; make it accept a `PlaylistDoc` observable rather than
416
+ app-specific state)
417
+ - [ ] reactive IDB wrapper: signal that re-emits when a doc handle fires a change
418
+ - [ ] media session bridge: `navigator.mediaSession` plumbing with no SolidJS
419
+ dep in `freqhole-api-client`, thin SolidJS `createEffect` wrapper in
420
+ `@freqhole/solid`
421
+ - [ ] both playlistz and spume depend on the package; no duplication
422
+
423
+ #### audit tomb/playlistz/ divergence
424
+
425
+ scope: read-only audit, then decision. low risk.
426
+
427
+ `tomb/playlistz/src/services/` is an earlier snapshot of playlistz services.
428
+ it lacks `p2pService.ts`, `blobTransferService.ts`, `streamingAudioService.ts`.
429
+
430
+ - [ ] run a diff: list which service files are in tomb/playlistz/ only, in
431
+ freqhole/playlistz/ only, and in both
432
+ - [ ] for files in both: diff them and note which direction is ahead
433
+ - [ ] output: a short audit doc or table summarising what to keep, migrate, or
434
+ drop. then this roadmap item updates with concrete action items.
435
+ - [ ] longer-term goal: `tomb/playlistz/` depends on `@freqhole/solid` rather
436
+ than carrying its own service copies
437
+
438
+ #### create @freqhole/e2e-utils package
439
+
440
+ scope: new package; no changes to existing tests.
441
+
442
+ `e2e/helpers/` contains utilities generic enough to share:
443
+ `makeWav`, `dropFiles`, `waitForApp`, `resetAppState`, `loadCommittedAudioFixtures`.
444
+
445
+ - [ ] scaffold `@freqhole/e2e-utils` package (alongside `freqhole-api-client`
446
+ or in a separate `packages/` dir)
447
+ - [ ] move the non-app-specific helpers from `e2e/helpers/media.ts` and
448
+ `e2e/helpers/app.ts` into the package
449
+ - [ ] playlistz `e2e/helpers/` becomes a thin wrapper that re-exports from
450
+ the package and adds app-specific helpers (`createPlaylistViaUI`, etc.)
451
+ - [ ] `tomb/` e2e suites can then import `@freqhole/e2e-utils` directly
452
+
453
+ #### audio layer convergence
454
+
455
+ scope: multi-step, depends on `@freqhole/solid` existing first.
456
+
457
+ - [ ] extract `navigator.mediaSession` plumbing into
458
+ `freqhole-api-client/src/audio/mediaSession.ts` (no SolidJS deps)
459
+ - [ ] thin `@freqhole/solid` wrapper: `createEffect` that syncs signals to
460
+ the media session API
461
+ - [ ] longer term: playlistz `audioService.ts` adopts spume's backend
462
+ abstraction (`playerState`, `backends/`) rather than its own raw
463
+ `HTMLAudioElement` wiring
464
+
465
+ ---
466
+
467
+ ## backlog
468
+
469
+ items that are real but not the current focus.
470
+
471
+ - **eslint-plugin-solid**: add the plugin and fix any violations. see "code
472
+ conventions" above for the specific rules and what to look for.
473
+ - **export audit**: each service file has too many exports. run
474
+ `eslint-plugin-import no-unused-modules` and remove dead exports.
475
+ - **songReactivity.ts workaround**: `triggerSpecificSongUpdate` is a workaround
476
+ for `SongRow` not reacting to doc changes directly. proper fix: derive from a
477
+ `playlistSongs()` signal (deferred to `@freqhole/solid` work).
478
+ - **access-control two-browser tests**: `sharing-access.spec.ts` covers mode
479
+ toggles single-browser only. real two-browser knock/unblock tests are queued
480
+ under access control above.
481
+ - **playback position persistence**: `savePlaybackPosition` exists in
482
+ `audioService.ts`; no e2e test that a reload resumes from saved position.
483
+ - **blob GC e2e coverage**: deletion logic is implemented; no e2e test yet.
484
+ - **repeat + shuffle e2e coverage**: signals exist, no e2e tests yet.
485
+
486
+ ---
487
+
488
+ ## completed (reference)
489
+
490
+ ### ui / panels
491
+
492
+ **sidebar refactor (§1)**
493
+
494
+ - `AllPlaylistsPanel.tsx` replaces `PlaylistSidebar.tsx` (deleted)
495
+ - hamburger toggle, `PanelMiniHeader` shared component, `MarqueeText`, row
496
+ actions (play/edit/share/download), `EmptyState` for fresh installs
497
+ - sidebar state removed from `useUIState.ts`
498
+ - e2e: `e2e/all-playlists.spec.ts` (10 tests)
499
+
500
+ **share link auto-play (§3a)**
501
+
502
+ - after `handleShareFragment()`: select playlist, auto-play if idle,
503
+ `pendingShareDocId` watches `playlists()` reactively for docs still syncing
504
+
505
+ **share panel inline layout (§3b)**
506
+
507
+ - share panel animates in the song-rows area (same pattern as edit panel)
508
+ - share button in playlist action row; `PlaylistSharePanel.tsx` inline with
509
+ `< >` nav, copy button, mode toggle
510
+
511
+ ---
512
+
513
+ ### p2p transport + state machine
514
+
515
+ **per-song download state signal (§12a)**
516
+
517
+ - `blobDownloadStates`: reactive `ReadonlyMap<sha256, "downloading" | "pending" | "error">`
518
+ - `SongRow`: downloading - `text-blue-400 animate-pulse`; pending - `text-gray-500`;
519
+ error - `text-red-400 cursor-pointer`; `data-download-state` attr for e2e
520
+ - error retry: clicking duration cell calls `fetchSongBlob(song())` directly
521
+
522
+ **audio player loading state (§12b)**
523
+
524
+ - `isFetchingBlob()` checks `blobDownloadStates` for current song sha
525
+ - spinner + `aria-busy="true"` while blob is fetching or audio is buffering
526
+
527
+ **prefetch window (§12c)**
528
+
529
+ - `PREFETCH_CONCURRENCY = 3`, parallel batches with `Promise.allSettled`
530
+ - `seeked` event re-calls `prefetchUpcoming` with recalculated remaining time
531
+ - `prefetchRun` counter cancels stale runs on playlist switch
532
+ - `pendingShas` + `clearPending()` for pending-state cleanup on cancellation
533
+
534
+ **fetch timeout + retry (§15a-c)**
535
+
536
+ - `BlobDownloadState` includes `"pending"` (queued but not yet started)
537
+ - `BLOB_FETCH_TIMEOUT_MS = 30_000` (configurable via `_devSetBlobFetchTimeout`);
538
+ applies to both dev-override and real p2p paths
539
+ - `_devFetchBlobBySha` + `window.__fetchBlobBySha` for programmatic retry in tests
540
+
541
+ **dev hooks + .dev.ts mock isolation pattern**
542
+
543
+ - `src/dev-hooks.ts`: 18-line registration glue, dynamically imported DEV-only
544
+ (`void import("../dev-hooks.js")` in `src/components/index.tsx`). never in prod.
545
+ - each service that needs dev/test hooks has a paired `service.dev.ts` file:
546
+ - `src/services/audioService.dev.ts` - exports `registerAudioDevHooks()`
547
+ - `src/services/blobTransferService.dev.ts` - exports `registerBlobDevHooks()`,
548
+ contains `MockBlobBehaviour` type + `mockFetchBlob()` implementation
549
+ - `_dev*` exports in the service file are injection points; only their `.dev.ts`
550
+ counterpart imports them
551
+ - `src/types/global.d.ts` declares all `Window.__*` hook props
552
+ - hooks: `__seekTo`, `__triggerTrackEnd`, `__triggerAudioError`, `__evictBlob`,
553
+ `__mockBlobFetch`, `__clearMockBlobFetch`, `__setBlobFetchTimeout`, `__fetchBlobBySha`
554
+
555
+ **e2e helpers split**
556
+
557
+ - `e2e/helpers/media.ts` - `makeWav()`, fixture loading, no `Page` dep, pure Node
558
+ - `e2e/helpers/app.ts` - `resetAppState`, `createPlaylistViaUI`, `addSongs`,
559
+ `makePng`, `dropFiles`, `waitForApp`, `setPlaylistCover`
560
+ - `e2e/helpers/hooks.ts` - typed `window.__*` wrappers + `MockBlobBehaviour` type
561
+ - `@mock` convention docs
562
+ - `e2e/helpers/index.ts` - barrel re-export of all three
563
+ - `e2e/helpers.ts` - 10-line re-export shim so existing `"./helpers.js"` imports
564
+ continue to work without changes
565
+
566
+ **e2e: audio player + mocked p2p (§12d, §12e, §15d)**
567
+
568
+ - `e2e/audio-player.spec.ts` (7 tests): autoplay-next, triggerTrackEnd, end-of-playlist
569
+ stopped state, real 2s fixture playthrough, error recovery, delayed blob state,
570
+ mock error state. two tests tagged `@mock`.
571
+ - `e2e/p2p-states.spec.ts` (6 tests, all tagged `@mock`): pending during stall,
572
+ timeout→error, retry affordance, prefetch triggered on play, prefetch cancelled
573
+ on playlist switch, seek recalculation
574
+
575
+ **playlist cover image reactive after reload**
576
+
577
+ - `PlaylistEditPanel.tsx`: moved image URL loading from `onMount` to `createEffect`
578
+ - root cause: after page reload, `docToPlaylistAsync` resolves `imageFilePath`
579
+ asynchronously from OPFS/Cache. `onMount` ran once before resolution and saw
580
+ `undefined`. `createEffect` re-runs whenever `props.playlist.imageFilePath`
581
+ becomes non-null.
582
+ - lesson: `onMount` = "run once with initial prop value". `createEffect` = "run
583
+ whenever tracked signals change". when a prop may resolve asynchronously,
584
+ `createEffect` is almost always the right choice.
585
+
586
+ ---
587
+
588
+ **dedup on import (§11b)**
589
+
590
+ - `addSongToPlaylist` checks sha256 before writing; same-sha songs skip upsert
591
+ - applies to both file-drop and zip re-import
592
+ - e2e: `zip reimport dedup` test in `zip-bundle.spec.ts`
593
+
594
+ **blob GC on playlist delete (§11d)**
595
+
596
+ - `deletePlaylist` enumerates sha refs, runs `shaRefsExcluding(docId)`, removes
597
+ unreferenced blobs (best-effort, `Promise.allSettled`)
598
+
599
+ ---
600
+
601
+ ### zip download (§5b-c)
602
+
603
+ - songs deduped by sha256 in zip; node_ids excluded
604
+ - `e2e/zip-bundle.spec.ts`: download trigger, zip structure, reimport roundtrip,
605
+ standalone http server, audio playback, `--http` CLI mode, dedup test
606
+
607
+ ---
608
+
609
+ ### access control (§4a-b)
610
+
611
+ - `list_playlists` + `blob_request` check `settings.mode` + `getAccessGrant`
612
+ - `e2e/sharing-access.spec.ts`: mode toggle, default knock-first, persistence,
613
+ knock inbox empty state, browse-a-peer section
614
+
615
+ ---
616
+
617
+ ### state correctness (§10a-b)
618
+
619
+ - `selectedPlaylistId` is the single writable signal; `selectedPlaylist` is a memo;
620
+ stale overwrites structurally impossible
621
+ - `window.__processFiles` dev hook in `components/index.tsx` (DEV only)
622
+
623
+ ---
624
+
625
+ ### logging (§9)
626
+
627
+ - `src/utils/log.ts`: trace < debug < info < warn < error
628
+ - runtime: `localStorage.logLevel` / `localStorage.logFilter`
629
+ - build-time: `VITE_LOG_LEVEL` + `VITE_LOG_FILTER`; vitest sets `VITE_LOG_LEVEL=error`
630
+
631
+ ---
632
+
633
+ ### e2e test files
634
+
635
+ | file | tests | tags | notes |
636
+ | --------------------------------- | ----- | --------- | ------------------------------------- |
637
+ | `e2e/sharing.spec.ts` | - | @p2p | two-browser, real iroh |
638
+ | `e2e/sharing-access.spec.ts` | - | - | access control, single-browser |
639
+ | `e2e/share-link-autoplay.spec.ts` | - | - | synthetic share fragment |
640
+ | `e2e/all-playlists.spec.ts` | 10 | - | hamburger panel |
641
+ | `e2e/song-cache-state.spec.ts` | 17 | - | blob cache states |
642
+ | `e2e/zip-bundle.spec.ts` | ~20 | - | zip download + standalone roundtrip |
643
+ | `e2e/audio-player.spec.ts` | 7 | 2x @mock | playback + mock blob |
644
+ | `e2e/p2p-states.spec.ts` | 6 | all @mock | mocked p2p transport scenarios |
645
+ | `e2e/playlist-crud.spec.ts` | 5 | - | create, edit, cover image persistence |
646
+
647
+ ---
648
+
649
+ ## notes
650
+
651
+ ### e2e selectors and test patterns
652
+
653
+ - `data-testid="song-duration"` - one per song row; also has `data-sha256` and
654
+ `data-download-state` attrs. always use this to select song row durations
655
+ (the playlist total-duration badge also shows time strings).
656
+ - `data-testid="btn-play-playlist"` - AudioPlayer button;
657
+ `aria-pressed="true"/"false"` (string); `aria-busy` while fetching/buffering
658
+ - `data-testid="now-playing-title"` - shows current song title (only when playing)
659
+ - `data-testid="app-ready"` - app boot sentinel (visually hidden `<h1>`)
660
+ - `data-testid="edit-panel"` - playlist edit panel root (for scoping child locators)
661
+ - clicking song rows: if the action-buttons overlay intercepts pointer events
662
+ (visible on hover/play state), use `page.evaluate` / `dispatchEvent("click")`
663
+ rather than `.click()` or `{ force: true }` - force clicks hit the wrong target
664
+ - `fetchBlobBySha(page, sha256)` programmatic retry (bypasses overlay entirely)
665
+
666
+ ### subagent dispatch notes
667
+
668
+ tasks in "modularization: concrete next steps" are designed to be independently
669
+ dispatched. when dispatching, provide:
670
+
671
+ 1. the task section from this doc as context
672
+ 2. the relevant source files to read first
673
+ 3. the constraint "all existing tests must pass after changes"
674
+
675
+ tasks that touch `tomb/` require the tomb workspace context too:
676
+
677
+ - `tomb/client-codegen/freqhole-api-client/` is the shared package directory
678
+ - `tomb/client/spume/` is the spume frontend
679
+
680
+ for linting tasks, the full list of errors from `eslint --max-warnings 0` is
681
+ useful starting context so the subagent doesn't need to discover violations first.
682
+
683
+ ### general
684
+
685
+ - iroh relay/nat is known flaky in two-browser tests: sometimes 8 min, sometimes
686
+ 13s. not fixable in test code alone.
687
+ - `fileProcessingService.ts` parses filename only (no ID3 tags). cover art via
688
+ `imageService.extractAlbumArt` (ID3v2 APIC parser).
689
+ - `getBlobObjectURL` caches URLs internally; do not revoke them.
690
+ - `PREFETCH_CONCURRENCY = 3`; `BLOB_FETCH_TIMEOUT_MS = 30_000` (let, configurable
691
+ via `_devSetBlobFetchTimeout`). timeout applies to both dev-override and real p2p paths.
692
+ - `songReactivity.ts`: `triggerSpecificSongUpdate` is a workaround for `SongRow`
693
+ not subscribing directly to the doc handle. the proper fix is deriving from a
694
+ `playlistSongs()` signal - but this touches reactivity deeply, defer until
695
+ `@freqhole/solid` work begins.