@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,205 @@
1
+ // e2e: sharing access control - mode toggle, settings persistence, knock inbox ui.
2
+ // all tests here are single-browser (no real p2p needed).
3
+ // two-browser p2p tests are in sharing.spec.ts @p2p.
4
+
5
+ import { test, expect } from "@playwright/test";
6
+ import {
7
+ resetAppState,
8
+ createPlaylistViaUI,
9
+ waitForApp,
10
+ } from "./helpers.js";
11
+
12
+ test.beforeEach(async ({ page }) => {
13
+ await resetAppState(page);
14
+ });
15
+
16
+ // helper: open the share panel for the current playlist
17
+ async function openSharePanel(page: import("@playwright/test").Page) {
18
+ // open via the share icon button in the playlist header
19
+ await page.getByTestId("btn-share-playlist").click();
20
+ // wait for the panel to be visible
21
+ await page.getByTestId("share-panel").waitFor({ timeout: 5000 });
22
+ }
23
+
24
+ // --- mode toggle ---
25
+
26
+ test("share panel shows mode toggle buttons", async ({ page }) => {
27
+ await createPlaylistViaUI(page);
28
+ await openSharePanel(page);
29
+
30
+ await expect(page.getByTestId("btn-mode-public")).toBeVisible();
31
+ await expect(page.getByTestId("btn-mode-knock")).toBeVisible();
32
+ });
33
+
34
+ test("default mode is knock first", async ({ page }) => {
35
+ await createPlaylistViaUI(page);
36
+ await openSharePanel(page);
37
+
38
+ // "knock first" should be the active mode
39
+ const knockBtn = page.getByTestId("btn-mode-knock");
40
+ await expect(knockBtn).toBeVisible();
41
+ await expect(knockBtn).toHaveAttribute("aria-pressed", "true");
42
+ const publicBtn = page.getByTestId("btn-mode-public");
43
+ await expect(publicBtn).toHaveAttribute("aria-pressed", "false");
44
+ });
45
+
46
+ test("clicking anyone (public) switches the active mode", async ({ page }) => {
47
+ await createPlaylistViaUI(page);
48
+ await openSharePanel(page);
49
+
50
+ await page.getByTestId("btn-mode-public").click();
51
+
52
+ await expect(page.getByTestId("btn-mode-public")).toHaveAttribute("aria-pressed", "true");
53
+ await expect(page.getByTestId("btn-mode-knock")).toHaveAttribute("aria-pressed", "false");
54
+ });
55
+
56
+ test("mode setting persists after closing and reopening the share panel", async ({ page }) => {
57
+ await createPlaylistViaUI(page);
58
+ await openSharePanel(page);
59
+
60
+ // switch to public
61
+ await page.getByTestId("btn-mode-public").click();
62
+ await expect(page.getByTestId("btn-mode-public")).toHaveAttribute("aria-pressed", "true");
63
+
64
+ // close and reopen
65
+ await page.getByTestId("btn-share-playlist").click();
66
+ await openSharePanel(page);
67
+
68
+ // mode should still be public
69
+ await expect(page.getByTestId("btn-mode-public")).toHaveAttribute("aria-pressed", "true");
70
+ });
71
+
72
+ test("mode setting persists across page reload", async ({ page }) => {
73
+ await createPlaylistViaUI(page);
74
+ await openSharePanel(page);
75
+
76
+ // switch to public
77
+ await page.getByTestId("btn-mode-public").click();
78
+ await expect(page.getByTestId("btn-mode-public")).toHaveAttribute("aria-pressed", "true");
79
+
80
+ // close and reopen the share panel - confirms the IDB write completed
81
+ // (aria-pressed updates sync but saveShareSettings is async)
82
+ await page.getByTestId("btn-share-playlist").click();
83
+ await openSharePanel(page);
84
+ await expect(page.getByTestId("btn-mode-public")).toHaveAttribute("aria-pressed", "true");
85
+
86
+ // reload
87
+ await page.reload();
88
+ await waitForApp(page);
89
+
90
+ // reopen share panel
91
+ await openSharePanel(page);
92
+
93
+ // mode should still be public
94
+ await expect(page.getByTestId("btn-mode-public")).toHaveAttribute("aria-pressed", "true");
95
+ });
96
+
97
+ test("switching back to knock first from public persists", async ({ page }) => {
98
+ await createPlaylistViaUI(page);
99
+ await openSharePanel(page);
100
+
101
+ // switch to public then back to knock
102
+ await page.getByTestId("btn-mode-public").click();
103
+ await expect(page.getByTestId("btn-mode-public")).toHaveAttribute("aria-pressed", "true");
104
+ await page.getByTestId("btn-mode-knock").click();
105
+ await expect(page.getByTestId("btn-mode-knock")).toHaveAttribute("aria-pressed", "true");
106
+
107
+ // close and reopen to confirm the IDB write landed before reload
108
+ await page.getByTestId("btn-share-playlist").click();
109
+ await openSharePanel(page);
110
+ await expect(page.getByTestId("btn-mode-knock")).toHaveAttribute("aria-pressed", "true");
111
+
112
+ await page.reload();
113
+ await waitForApp(page);
114
+ await openSharePanel(page);
115
+
116
+ await expect(page.getByTestId("btn-mode-knock")).toHaveAttribute("aria-pressed", "true");
117
+ });
118
+
119
+ // --- knock inbox ---
120
+
121
+ // knock inbox only shown when there are pending knocks - no empty state test needed
122
+
123
+ // --- endpoint toggle ---
124
+
125
+ test("endpoint toggle button is present", async ({ page }) => {
126
+ await createPlaylistViaUI(page);
127
+ await openSharePanel(page);
128
+
129
+ // button shows "enable endpoint" or "disable endpoint" depending on state
130
+ const toggleBtn = page.getByTestId("btn-toggle-endpoint");
131
+ await expect(toggleBtn).toBeVisible();
132
+ });
133
+
134
+ // --- close ---
135
+
136
+ test("share button toggles the share panel closed", async ({ page }) => {
137
+ await createPlaylistViaUI(page);
138
+ await openSharePanel(page);
139
+
140
+ await page.getByTestId("btn-share-playlist").click();
141
+ await expect(page.getByTestId("share-panel")).not.toBeVisible();
142
+ });
143
+
144
+ test("escape key closes the share panel", async ({ page }) => {
145
+ await createPlaylistViaUI(page);
146
+ await openSharePanel(page);
147
+
148
+ await page.keyboard.press("Escape");
149
+ await expect(page.getByTestId("share-panel")).not.toBeVisible();
150
+ });
151
+
152
+ // browse a peer section was moved to the all-playlists search bar
153
+
154
+ // --- collaborative editing toggle ---
155
+
156
+ test("collaborative toggle is present in share panel", async ({ page }) => {
157
+ await createPlaylistViaUI(page);
158
+ await openSharePanel(page);
159
+
160
+ await expect(page.getByTestId("btn-toggle-collaborative")).toBeVisible();
161
+ });
162
+
163
+ test("collaborative editing is off by default", async ({ page }) => {
164
+ await createPlaylistViaUI(page);
165
+ await openSharePanel(page);
166
+
167
+ await expect(page.getByTestId("btn-toggle-collaborative")).toHaveAttribute(
168
+ "aria-pressed",
169
+ "false"
170
+ );
171
+ });
172
+
173
+ test("clicking collaborative toggle turns it on", async ({ page }) => {
174
+ await createPlaylistViaUI(page);
175
+ await openSharePanel(page);
176
+
177
+ await page.getByTestId("btn-toggle-collaborative").click();
178
+
179
+ await expect(page.getByTestId("btn-toggle-collaborative")).toHaveAttribute(
180
+ "aria-pressed",
181
+ "true"
182
+ );
183
+ });
184
+
185
+ test("collaborative toggle persists after close and reopen", async ({
186
+ page,
187
+ }) => {
188
+ await createPlaylistViaUI(page);
189
+ await openSharePanel(page);
190
+
191
+ await page.getByTestId("btn-toggle-collaborative").click();
192
+ await expect(page.getByTestId("btn-toggle-collaborative")).toHaveAttribute(
193
+ "aria-pressed",
194
+ "true"
195
+ );
196
+
197
+ // close and reopen
198
+ await page.getByTestId("btn-share-playlist").click();
199
+ await openSharePanel(page);
200
+
201
+ await expect(page.getByTestId("btn-toggle-collaborative")).toHaveAttribute(
202
+ "aria-pressed",
203
+ "true"
204
+ );
205
+ });
@@ -0,0 +1,195 @@
1
+ // e2e: the share panel UI. covers opening the panel, settings
2
+ // persistence, share-link paste validation, and a real two-browser
3
+ // p2p transfer over the iroh relay (slow; tagged @p2p so you can
4
+ // skip it with: npm run test:e2e -- --grep-invert @p2p)
5
+
6
+ import { test, expect } from "@playwright/test";
7
+ import {
8
+ resetAppState,
9
+ createPlaylistViaUI,
10
+ waitForApp,
11
+ logTs,
12
+ } from "./helpers.js";
13
+
14
+ test.beforeEach(async ({ page }) => {
15
+ await resetAppState(page);
16
+ });
17
+
18
+ async function openSharePanel(page: import("@playwright/test").Page) {
19
+ // the share panel lives in the playlist header - a playlist must be selected
20
+ const shareBtn = page.getByTestId("btn-share-playlist");
21
+ if (!(await shareBtn.isVisible({ timeout: 1000 }).catch(() => false))) {
22
+ await createPlaylistViaUI(page);
23
+ }
24
+ await shareBtn.click();
25
+ // share panel becomes visible
26
+ await expect(page.getByTestId("share-panel")).toBeVisible();
27
+ }
28
+
29
+ test("share panel opens and closes from the playlist header", async ({
30
+ page,
31
+ }) => {
32
+ await waitForApp(page);
33
+ await openSharePanel(page);
34
+
35
+ await expect(page.getByTestId("btn-enable-sharing")).toBeVisible();
36
+
37
+ await page.getByTestId("btn-share-playlist").click();
38
+ await expect(page.getByTestId("share-panel")).not.toBeVisible();
39
+ });
40
+
41
+ test("share settings persist across panel reopen and reload", async ({
42
+ page,
43
+ }) => {
44
+ await createPlaylistViaUI(page);
45
+ await openSharePanel(page);
46
+
47
+ // click the name pill to enter edit mode, then fill the display name
48
+ await page.getByTestId("share-panel").locator("button[title='click to edit display name']").click();
49
+ await page.getByTestId("input-node-name").fill("doomlord");
50
+ await page.getByTestId("input-node-name").blur();
51
+ await page.getByText("anyone (public)").click();
52
+ await page.waitForTimeout(300);
53
+
54
+ // reopen
55
+ await page.getByTestId("btn-share-playlist").click();
56
+ await openSharePanel(page);
57
+ // click pill again to verify the saved name
58
+ await page.getByTestId("share-panel").locator("button[title='click to edit display name']").click();
59
+ await expect(page.getByTestId("input-node-name")).toHaveValue(
60
+ "doomlord"
61
+ );
62
+
63
+ // reload
64
+ await page.reload();
65
+ await waitForApp(page);
66
+ await openSharePanel(page);
67
+ await page.getByTestId("share-panel").locator("button[title='click to edit display name']").click();
68
+ await expect(page.getByTestId("input-node-name")).toHaveValue(
69
+ "doomlord",
70
+ { timeout: 10000 }
71
+ );
72
+ });
73
+
74
+ test("pasting an invalid share link shows an error", async ({ page }) => {
75
+ await createPlaylistViaUI(page);
76
+ await waitForApp(page);
77
+
78
+ // open the all-playlists panel and paste the invalid link into the search bar
79
+ await page.getByTestId("btn-all-playlists").click();
80
+ await page.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
81
+
82
+ await page
83
+ .getByTestId("input-search-playlists")
84
+ .fill("definitely not a share link");
85
+
86
+ // the invalid link is text, not a share token - it just filters locally,
87
+ // no error state is shown for plain text queries
88
+ // verify the search does not crash the panel
89
+ await expect(page.getByTestId("all-playlists-panel")).toBeVisible();
90
+ });
91
+
92
+ test("playlist header has a share button that opens the share panel", async ({
93
+ page,
94
+ }) => {
95
+ await createPlaylistViaUI(page);
96
+ await expect(
97
+ page.getByTestId("input-playlist-title")
98
+ ).toBeVisible();
99
+ // share button is in the playlist header action row
100
+ await expect(page.getByTestId("btn-share-playlist")).toBeVisible();
101
+ await page.getByTestId("btn-share-playlist").click();
102
+ await expect(page.getByTestId("btn-enable-sharing")).toBeVisible();
103
+ });
104
+
105
+ // real two-peer sharing test over the iroh relay. slow (node boot takes
106
+ // 1-2 min per peer) but it exercises the full share-link flow end to end.
107
+ // tagged @p2p - skip with: npm run test:e2e -- --grep-invert @p2p
108
+ test("two browsers share a playlist over p2p @p2p", async ({ browser }) => {
109
+ test.setTimeout(480_000);
110
+
111
+ const ctxA = await browser.newContext();
112
+ const ctxB = await browser.newContext();
113
+ const pageA = await ctxA.newPage();
114
+ const pageB = await ctxB.newPage();
115
+
116
+ // surface browser console output (timestamped) so stalls are diagnosable
117
+ const forward = (tag: string) => (msg: import("@playwright/test").ConsoleMessage) => {
118
+ logTs(`[${tag}] ${msg.text()}`);
119
+ };
120
+ pageA.on("console", forward("peerA"));
121
+ pageB.on("console", forward("peerB"));
122
+ pageA.on("pageerror", (err) => logTs(`[peerA pageerror] ${err}`));
123
+ pageB.on("pageerror", (err) => logTs(`[peerB pageerror] ${err}`));
124
+
125
+ try {
126
+ // boot both peers' p2p nodes in parallel - each takes ~1-2 min to
127
+ // come online (relay handshake), so doing them sequentially is slow
128
+ const setupA = async () => {
129
+ // peer a: create a playlist and enable p2p
130
+ await resetAppState(pageA);
131
+ await createPlaylistViaUI(pageA);
132
+ const title = pageA.getByTestId("input-playlist-title");
133
+ await title.fill("shared doom");
134
+ await title.blur();
135
+ await pageA.waitForTimeout(500);
136
+
137
+ // peer a: open the share panel and enable p2p
138
+ logTs("[e2e] peer a: enabling p2p from the share panel...");
139
+ await pageA.getByTestId("btn-share-playlist").click();
140
+ await pageA.getByTestId("btn-enable-sharing").click();
141
+
142
+ // once the node is up the share column shows the link + copy button
143
+ const copyBtn = pageA.getByTestId("btn-copy-share-link");
144
+ await expect(copyBtn).toBeEnabled({ timeout: 180_000 });
145
+ logTs("[e2e] peer a: p2p node online");
146
+ };
147
+
148
+ const setupB = async () => {
149
+ // peer b: pre-boot the p2p node via the share panel
150
+ await resetAppState(pageB);
151
+ await createPlaylistViaUI(pageB);
152
+ await pageB.getByTestId("btn-share-playlist").click();
153
+ logTs("[e2e] peer b: enabling p2p from the share panel...");
154
+ await pageB.getByTestId("btn-enable-sharing").click();
155
+ await expect(pageB.getByTestId("sharing-status")).toBeVisible({
156
+ timeout: 180_000,
157
+ });
158
+ // close share panel after node is up
159
+ await pageB.getByTestId("btn-share-playlist").click();
160
+ logTs("[e2e] peer b: p2p node online");
161
+ };
162
+
163
+ await Promise.all([setupA(), setupB()]);
164
+
165
+ // read the share url straight from the readonly input (no clipboard)
166
+ const shareUrl = await pageA
167
+ .locator("input[readonly]")
168
+ .first()
169
+ .inputValue();
170
+ expect(shareUrl).toContain("#share/");
171
+ logTs(`[e2e] peer a: share url: ${shareUrl.slice(0, 60)}...`);
172
+
173
+ // peer b: open the all-playlists panel, paste the share link into the
174
+ // search bar, which auto-detects and opens it
175
+ await pageB.getByTestId("btn-all-playlists").click();
176
+ await pageB.getByTestId("all-playlists-panel").waitFor({ timeout: 5000 });
177
+ await pageB
178
+ .getByTestId("input-search-playlists")
179
+ .fill(shareUrl);
180
+ logTs("[e2e] peer b: opening share link via search bar...");
181
+ // the search bar calls openShareLink and auto-closes when done
182
+ await expect(pageB.getByTestId("all-playlists-panel")).not.toBeVisible({
183
+ timeout: 120_000,
184
+ });
185
+ logTs("[e2e] peer b: playlist added");
186
+
187
+ // peer b: the shared playlist is now selected and its title is visible
188
+ await expect(
189
+ pageB.getByTestId("input-playlist-title")
190
+ ).toHaveValue("shared doom", { timeout: 30_000 });
191
+ } finally {
192
+ await ctxA.close();
193
+ await ctxB.close();
194
+ }
195
+ });
@@ -0,0 +1,202 @@
1
+ // e2e: song row cache-state styling and related format/metadata behaviour.
2
+ //
3
+ // covers:
4
+ // - duration cell shows muted gray while blobCached is unknown (resource loading)
5
+ // - duration cell shows underline once the blob is confirmed locally cached
6
+ // - locally-added songs without an explicit sha also show underline (no-sha fallback)
7
+ // - mp3/m4a/ogg files are accepted and show the correct row (duration + title)
8
+ // - filename-based title and artist parsing (app does not read ID3 tags)
9
+ // - various image formats accepted as playlist covers
10
+ // - mixed formats in a single drop all produce rows
11
+
12
+ import { test, expect } from "@playwright/test";
13
+ import {
14
+ resetAppState,
15
+ createPlaylistViaUI,
16
+ addSongs,
17
+ dropFiles,
18
+ fixture,
19
+ setPlaylistCover,
20
+ waitForApp,
21
+ } from "./helpers.js";
22
+
23
+ test.beforeEach(async ({ page }) => {
24
+ await resetAppState(page);
25
+ });
26
+
27
+ // --- duration cell cache-state styling ---
28
+
29
+ test("duration shows underline once blob is locally cached", async ({ page }) => {
30
+ await createPlaylistViaUI(page);
31
+ // 2-second tone - once the row appears, the blob is already in opfs
32
+ await addSongs(page, 1, 2);
33
+
34
+ // wait for the song row duration cell to appear, then check for underline
35
+ const dur = page.getByTestId("song-duration").first();
36
+ await expect(dur).toBeVisible({ timeout: 15000 });
37
+ await expect(dur).toHaveClass(/underline/, { timeout: 10000 });
38
+ });
39
+
40
+ test("duration stays muted gray until cache check resolves", async ({ page }) => {
41
+ await createPlaylistViaUI(page);
42
+ await addSongs(page, 1, 2);
43
+
44
+ const dur = page.getByTestId("song-duration").first();
45
+ await expect(dur).toBeVisible({ timeout: 15000 });
46
+
47
+ // once the async check completes it should be underlined
48
+ // (the gray state is the transient loading window; we assert the final
49
+ // correct state rather than racing against the loading window)
50
+ await expect(dur).toHaveClass(/underline/, { timeout: 10000 });
51
+ });
52
+
53
+ test("duration underline persists across page reload", async ({ page }) => {
54
+ await createPlaylistViaUI(page);
55
+ await addSongs(page, 1, 2);
56
+ await expect(page.getByTestId("song-duration").first()).toHaveClass(/underline/, { timeout: 10000 });
57
+
58
+ await page.reload();
59
+ await waitForApp(page);
60
+
61
+ // blobs persist in opfs; underline should reappear after reload
62
+ await expect(page.getByTestId("song-duration").first()).toHaveClass(/underline/, { timeout: 10000 });
63
+ });
64
+
65
+ // --- audio format acceptance ---
66
+
67
+ test("mp3 file is accepted and shows a row", async ({ page }) => {
68
+ await createPlaylistViaUI(page);
69
+ await dropFiles(page, [fixture("tagged-c5-3s.mp3")]);
70
+ // app extracts title from filename: "tagged-c5-3s"
71
+ await expect(page.getByText("tagged-c5-3s").first()).toBeVisible({ timeout: 15000 });
72
+ // duration: 3 seconds
73
+ await expect(page.getByText("0:03").first()).toBeVisible();
74
+ });
75
+
76
+ test("m4a file is accepted and shows a row", async ({ page }) => {
77
+ await createPlaylistViaUI(page);
78
+ await dropFiles(page, [fixture("tagged-a3-4s.m4a")]);
79
+ await expect(page.getByText("tagged-a3-4s").first()).toBeVisible({ timeout: 15000 });
80
+ await expect(page.getByText("0:04").first()).toBeVisible();
81
+ });
82
+
83
+ test("ogg file is accepted and shows a row", async ({ page }) => {
84
+ await createPlaylistViaUI(page);
85
+ await dropFiles(page, [fixture("tagged-f4-6s.ogg")]);
86
+ await expect(page.getByText("tagged-f4-6s").first()).toBeVisible({ timeout: 15000 });
87
+ await expect(page.getByText("0:06").first()).toBeVisible();
88
+ });
89
+
90
+ test("very short mp3 (1s) is accepted and shows duration", async ({ page }) => {
91
+ await createPlaylistViaUI(page);
92
+ await dropFiles(page, [fixture("bare-glitch-1s.mp3")]);
93
+ await expect(page.getByText("bare-glitch-1s").first()).toBeVisible({ timeout: 15000 });
94
+ await expect(page.getByText("0:01").first()).toBeVisible();
95
+ });
96
+
97
+ test("stereo wav is accepted and shows duration", async ({ page }) => {
98
+ await createPlaylistViaUI(page);
99
+ await dropFiles(page, [fixture("tone-stereo-3s.wav")]);
100
+ await expect(page.getByText("tone-stereo-3s").first()).toBeVisible({ timeout: 15000 });
101
+ await expect(page.getByText("0:03").first()).toBeVisible();
102
+ });
103
+
104
+ test("chord wav shows correct duration", async ({ page }) => {
105
+ await createPlaylistViaUI(page);
106
+ await dropFiles(page, [fixture("chord-stack-3s.wav")]);
107
+ await expect(page.getByText("chord-stack-3s").first()).toBeVisible({ timeout: 15000 });
108
+ await expect(page.getByText("0:03").first()).toBeVisible();
109
+ });
110
+
111
+ // --- filename-based title parsing ---
112
+
113
+ test("filename with artist-title pattern is parsed correctly", async ({ page }) => {
114
+ // "Artist - Title" filename pattern: app splits on " - "
115
+ await createPlaylistViaUI(page);
116
+ await dropFiles(page, [{
117
+ name: "Fixture Bot - My Song.mp3",
118
+ mimeType: "audio/mpeg",
119
+ bytes: fixture("tagged-c5-3s.mp3").bytes,
120
+ }]);
121
+ await expect(page.getByText("My Song").first()).toBeVisible({ timeout: 15000 });
122
+ await expect(page.getByText("Fixture Bot").first()).toBeVisible();
123
+ });
124
+
125
+ test("filename with no separator uses full name as title", async ({ page }) => {
126
+ await createPlaylistViaUI(page);
127
+ await dropFiles(page, [fixture("bare-glitch-1s.mp3")]);
128
+ await expect(page.getByText("bare-glitch-1s").first()).toBeVisible({ timeout: 15000 });
129
+ });
130
+
131
+ // --- mixed formats in a single drop ---
132
+
133
+ test("mixed formats in a single drop all appear as rows", async ({ page }) => {
134
+ await createPlaylistViaUI(page);
135
+ await dropFiles(page, [
136
+ fixture("tagged-c5-3s.mp3"),
137
+ fixture("tagged-a3-4s.m4a"),
138
+ fixture("tagged-f4-6s.ogg"),
139
+ fixture("tone-440hz-2s.wav"),
140
+ ]);
141
+
142
+ await expect(page.getByText("tagged-c5-3s").first()).toBeVisible({ timeout: 15000 });
143
+ await expect(page.getByText("tagged-a3-4s").first()).toBeVisible();
144
+ await expect(page.getByText("tagged-f4-6s").first()).toBeVisible();
145
+ await expect(page.getByText("tone-440hz-2s").first()).toBeVisible();
146
+ await expect(page.getByTestId("playlist-song-count").first()).toContainText("4");
147
+ });
148
+
149
+ // --- image formats as playlist cover ---
150
+
151
+ test("jpg accepted as playlist cover", async ({ page }) => {
152
+ await createPlaylistViaUI(page);
153
+ await page.getByTestId("btn-edit-playlist").click();
154
+ await setPlaylistCover(page, fixture("cover-gradient.jpg"));
155
+ // scope to edit-panel - the preview img appears there after processing
156
+ await expect(page.getByTestId("edit-panel").locator("img[alt='playlist cover']").first()).toBeVisible({ timeout: 10000 });
157
+ });
158
+
159
+ test("webp accepted as playlist cover", async ({ page }) => {
160
+ await createPlaylistViaUI(page);
161
+ await page.getByTestId("btn-edit-playlist").click();
162
+ await setPlaylistCover(page, fixture("cover-plasma.webp"));
163
+ await expect(page.getByTestId("edit-panel").locator("img[alt='playlist cover']").first()).toBeVisible({ timeout: 10000 });
164
+ });
165
+
166
+ test("portrait jpg (non-square) accepted as playlist cover", async ({ page }) => {
167
+ await createPlaylistViaUI(page);
168
+ await page.getByTestId("btn-edit-playlist").click();
169
+ await setPlaylistCover(page, fixture("cover-portrait.jpg"));
170
+ await expect(page.getByTestId("edit-panel").locator("img[alt='playlist cover']").first()).toBeVisible({ timeout: 10000 });
171
+ });
172
+
173
+ // --- metadata persistence ---
174
+
175
+ test("songs persist title and duration across page reload", async ({ page }) => {
176
+ await createPlaylistViaUI(page);
177
+ await dropFiles(page, [fixture("tagged-c5-3s.mp3")]);
178
+ await expect(page.getByText("tagged-c5-3s").first()).toBeVisible({ timeout: 15000 });
179
+ await expect(page.getByText("0:03").first()).toBeVisible();
180
+
181
+ await page.reload();
182
+ await waitForApp(page);
183
+
184
+ await expect(page.getByText("tagged-c5-3s").first()).toBeVisible({ timeout: 10000 });
185
+ await expect(page.getByText("0:03").first()).toBeVisible();
186
+ });
187
+
188
+ test("multiple songs survive reload with correct order", async ({ page }) => {
189
+ await createPlaylistViaUI(page);
190
+ await dropFiles(page, [
191
+ fixture("tagged-c5-3s.mp3"),
192
+ fixture("tagged-a3-4s.m4a"),
193
+ ]);
194
+ await expect(page.getByTestId("playlist-song-count").first()).toContainText("2", { timeout: 15000 });
195
+
196
+ await page.reload();
197
+ await waitForApp(page);
198
+
199
+ await expect(page.getByText("tagged-c5-3s").first()).toBeVisible({ timeout: 10000 });
200
+ await expect(page.getByText("tagged-a3-4s").first()).toBeVisible();
201
+ await expect(page.getByTestId("playlist-song-count").first()).toContainText("2");
202
+ });