@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,238 @@
1
+ // e2e test matrix infrastructure.
2
+ //
3
+ // exports SERVE_CONTEXTS, VIEWPORTS, withContext, and describeMatrix so
4
+ // spec files can run the same scenarios across multiple serving modes and
5
+ // viewport sizes without duplicating setup code.
6
+ //
7
+ // note: resetAppState() relies on navigating to "/" and clearing indexeddb
8
+ // on the vite dev server origin. it does NOT work in zip-http context because
9
+ // that browser context has a different origin and no dev-server idb to clear.
10
+
11
+ import { test, type Page, type BrowserContext } from "@playwright/test";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as http from "node:http";
15
+ import * as os from "node:os";
16
+ import { execSync } from "node:child_process";
17
+ import JSZip from "jszip";
18
+
19
+ // --- serve context descriptors ---
20
+
21
+ export type ServeContextName = "vite" | "zip-http";
22
+
23
+ export interface ServeContext {
24
+ name: ServeContextName;
25
+ /** base url used to navigate the page, set during withContext setup */
26
+ baseURL: string;
27
+ }
28
+
29
+ export const SERVE_CONTEXTS: ServeContext[] = [
30
+ { name: "vite", baseURL: "http://localhost:5917" },
31
+ { name: "zip-http", baseURL: "" }, // baseURL filled in dynamically during setup
32
+ ];
33
+
34
+ // --- viewport configs ---
35
+
36
+ export interface ViewportConfig {
37
+ name: string;
38
+ width: number;
39
+ height: number;
40
+ }
41
+
42
+ export const VIEWPORTS: ViewportConfig[] = [
43
+ { name: "desktop", width: 1400, height: 900 },
44
+ { name: "tablet", width: 768, height: 1024 },
45
+ { name: "mobile", width: 390, height: 844 },
46
+ ];
47
+
48
+ // --- static server (for zip-http context) ---
49
+
50
+ const MIME: Record<string, string> = {
51
+ ".html": "text/html",
52
+ ".js": "application/javascript",
53
+ ".css": "text/css",
54
+ ".json": "application/json",
55
+ ".mp3": "audio/mpeg",
56
+ ".wav": "audio/wav",
57
+ ".flac": "audio/flac",
58
+ ".ogg": "audio/ogg",
59
+ ".jpg": "image/jpeg",
60
+ ".jpeg": "image/jpeg",
61
+ ".png": "image/png",
62
+ ".map": "application/json",
63
+ };
64
+
65
+ export function startStaticServer(dir: string, port: number): Promise<http.Server> {
66
+ const server = http.createServer((req, res) => {
67
+ const rawPath = req.url?.split("?")[0] ?? "/";
68
+ const urlPath = decodeURIComponent(rawPath);
69
+ const filePath = path.join(dir, urlPath === "/" ? "index.html" : urlPath);
70
+
71
+ const absDir = path.resolve(dir);
72
+ const absFile = path.resolve(filePath);
73
+ if (!absFile.startsWith(absDir + path.sep) && absFile !== absDir) {
74
+ res.writeHead(403); res.end("forbidden"); return;
75
+ }
76
+ if (!fs.existsSync(absFile) || fs.statSync(absFile).isDirectory()) {
77
+ res.writeHead(404); res.end("not found"); return;
78
+ }
79
+
80
+ const ext = path.extname(absFile).toLowerCase();
81
+ const mime = MIME[ext] ?? "application/octet-stream";
82
+ const total = fs.statSync(absFile).size;
83
+ const range = req.headers["range"];
84
+
85
+ if (range) {
86
+ const [, rangeStr] = range.split("=");
87
+ const [s, e] = (rangeStr ?? "").split("-");
88
+ const start = parseInt(s ?? "0", 10);
89
+ const end = e ? parseInt(e, 10) : total - 1;
90
+ res.writeHead(206, {
91
+ "Content-Range": `bytes ${start}-${end}/${total}`,
92
+ "Accept-Ranges": "bytes",
93
+ "Content-Length": end - start + 1,
94
+ "Content-Type": mime,
95
+ });
96
+ fs.createReadStream(absFile, { start, end }).pipe(res);
97
+ } else {
98
+ res.writeHead(200, {
99
+ "Content-Length": total,
100
+ "Content-Type": mime,
101
+ "Accept-Ranges": "bytes",
102
+ });
103
+ fs.createReadStream(absFile).pipe(res);
104
+ }
105
+ });
106
+
107
+ return new Promise((resolve, reject) => {
108
+ server.on("error", reject);
109
+ server.listen(port, () => resolve(server));
110
+ });
111
+ }
112
+
113
+ // extract a jszip buffer to a directory. returns the output dir.
114
+ export async function extractZip(zipBuffer: Buffer, outDir: string): Promise<string> {
115
+ const zip = await JSZip.loadAsync(zipBuffer);
116
+ const writes: Promise<void>[] = [];
117
+ zip.forEach((relativePath, file) => {
118
+ if (file.dir) return;
119
+ const dest = path.join(outDir, relativePath);
120
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
121
+ writes.push(file.async("nodebuffer").then((buf) => fs.writeFileSync(dest, buf)));
122
+ });
123
+ await Promise.all(writes);
124
+ return outDir;
125
+ }
126
+
127
+ // find the first subdirectory containing index.html, or fall back to outDir.
128
+ export function findRootDir(outDir: string): string {
129
+ for (const entry of fs.readdirSync(outDir)) {
130
+ const sub = path.join(outDir, entry);
131
+ if (fs.statSync(sub).isDirectory() && fs.existsSync(path.join(sub, "index.html"))) {
132
+ return sub;
133
+ }
134
+ }
135
+ return outDir;
136
+ }
137
+
138
+ const REPO_ROOT = path.join(path.dirname(new URL(import.meta.url).pathname), "..");
139
+ const BUNDLE_PATH = path.join(REPO_ROOT, "dist", "freqhole-playlistz.js");
140
+
141
+ export function ensureBundleBuilt(): void {
142
+ if (fs.existsSync(BUNDLE_PATH)) return;
143
+ console.log("[contexts] dist/freqhole-playlistz.js not found - running build:standalone...");
144
+ execSync("npm run build:standalone", { cwd: REPO_ROOT, stdio: "inherit" });
145
+ if (!fs.existsSync(BUNDLE_PATH)) {
146
+ throw new Error("build:standalone did not produce dist/freqhole-playlistz.js");
147
+ }
148
+ }
149
+
150
+ // port range 5930-5939 (avoids collision with vite 5917, standalone 5920/5921)
151
+ const ZIP_HTTP_BASE_PORT = 5930;
152
+
153
+ // --- withContext helper ---
154
+
155
+ // callback signature used inside withContext
156
+ export type ContextTestFn = (page: Page, ctx: ServeContext) => Promise<void>;
157
+
158
+ // wraps a test body with the appropriate setup for the given serve context.
159
+ //
160
+ // vite: calls fn(page, ctx) directly - the page is already on the vite origin.
161
+ // zip-http: downloads a zip from the vite dev server (requires a playlist with
162
+ // songs to already exist on the page), extracts it, starts a static server,
163
+ // opens a fresh browser context at that origin, calls fn(standalonePage, ctx),
164
+ // then tears down. the port offset is used to avoid collisions when multiple
165
+ // zip-http contexts run in the same process.
166
+ export async function withContext(
167
+ ctx: ServeContext,
168
+ page: Page,
169
+ fn: ContextTestFn,
170
+ portOffset = 0
171
+ ): Promise<void> {
172
+ if (ctx.name === "vite") {
173
+ await fn(page, ctx);
174
+ return;
175
+ }
176
+
177
+ if (ctx.name === "zip-http") {
178
+ const port = ZIP_HTTP_BASE_PORT + portOffset;
179
+
180
+ // download zip from the current vite dev page (caller must have a playlist ready)
181
+ const downloadPromise = page.waitForEvent("download", { timeout: 30000 });
182
+ await page.getByTestId("btn-download-zip").click();
183
+ const download = await downloadPromise;
184
+ const zipPath = await download.path();
185
+ if (!zipPath) throw new Error("zip download path is null");
186
+
187
+ const zipBuf = fs.readFileSync(zipPath);
188
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "playlistz-ctx-"));
189
+ await extractZip(zipBuf, tmpDir);
190
+ const serveDir = findRootDir(tmpDir);
191
+
192
+ const server = await startStaticServer(serveDir, port);
193
+ const origin = `http://localhost:${port}`;
194
+ const resolvedCtx: ServeContext = { name: "zip-http", baseURL: origin };
195
+
196
+ let browserCtx: BrowserContext | undefined;
197
+ try {
198
+ browserCtx = await page.context().browser()!.newContext();
199
+ const standalonePage = await browserCtx.newPage();
200
+ await standalonePage.goto(`${origin}/`);
201
+ await standalonePage.waitForSelector('[data-testid="app-ready"]', { timeout: 15000 });
202
+ await fn(standalonePage, resolvedCtx);
203
+ } finally {
204
+ await browserCtx?.close();
205
+ await new Promise<void>((res) => server.close(() => res()));
206
+ fs.rmSync(tmpDir, { recursive: true, force: true });
207
+ }
208
+ }
209
+ }
210
+
211
+ // --- describeMatrix ---
212
+
213
+ // suite body callback receives the serve context (available at describe-definition
214
+ // time) and the viewport config. page is accessed via the normal playwright
215
+ // { page } fixture inside each test() block, then passed to withContext().
216
+ //
217
+ // usage:
218
+ // describeMatrix("my feature", (ctx, vp) => {
219
+ // test("does something", async ({ page }) => {
220
+ // await withContext(ctx, page, async (ctxPage) => {
221
+ // await expect(ctxPage.getByTestId("app-ready")).toBeVisible();
222
+ // });
223
+ // });
224
+ // });
225
+ export type MatrixSuiteFn = (ctx: ServeContext, vp: ViewportConfig) => void;
226
+
227
+ // generates a test.describe block for each SERVE_CONTEXT x VIEWPORT combination,
228
+ // applying the viewport via test.use() before the suite body runs.
229
+ export function describeMatrix(label: string, suiteBody: MatrixSuiteFn): void {
230
+ for (const ctx of SERVE_CONTEXTS) {
231
+ for (const vp of VIEWPORTS) {
232
+ test.describe(`${label} [${ctx.name} / ${vp.name}]`, () => {
233
+ test.use({ viewport: { width: vp.width, height: vp.height } });
234
+ suiteBody({ ...ctx }, vp);
235
+ });
236
+ }
237
+ }
238
+ }
@@ -0,0 +1,87 @@
1
+ // e2e: edit panel open/close behaviour.
2
+ //
3
+ // covers the "panel only opens every other time" bug: with songs present,
4
+ // toggling the edit button repeatedly must show the panel on every open.
5
+
6
+ import { test, expect } from "@playwright/test";
7
+ import { resetAppState, createPlaylistViaUI, addSongs } from "./helpers.js";
8
+
9
+ test.beforeEach(async ({ page }) => {
10
+ await resetAppState(page);
11
+ });
12
+
13
+ // the playlist edit panel is identified by its delete-confirm + cover upload area;
14
+ // the simplest stable marker is the close button rendered inside the panel
15
+ const panel = (page: import("@playwright/test").Page) =>
16
+ page.locator("input[type='file']");
17
+
18
+ test("edit panel opens on every toggle (empty playlist)", async ({ page }) => {
19
+ await createPlaylistViaUI(page);
20
+
21
+ for (let i = 0; i < 4; i++) {
22
+ await page.getByTestId("btn-edit-playlist").click();
23
+ await expect(panel(page).first()).toBeAttached({ timeout: 3000 });
24
+
25
+ await page.getByTestId("btn-edit-playlist").click();
26
+ await expect(panel(page)).toHaveCount(0, { timeout: 3000 });
27
+ await expect(page.getByTestId("btn-edit-playlist")).toBeVisible();
28
+ }
29
+ });
30
+
31
+ test("edit panel opens on every toggle (playlist with songs)", async ({
32
+ page,
33
+ }) => {
34
+ await createPlaylistViaUI(page);
35
+ await addSongs(page, 5);
36
+
37
+ for (let i = 0; i < 4; i++) {
38
+ await page.getByTestId("btn-edit-playlist").click();
39
+ await expect(panel(page).first()).toBeAttached({ timeout: 3000 });
40
+
41
+ await page.getByTestId("btn-edit-playlist").click();
42
+ await expect(panel(page)).toHaveCount(0, { timeout: 3000 });
43
+ // rows fly back in
44
+ await expect(page.getByText("song-00")).toBeVisible({ timeout: 3000 });
45
+ }
46
+ });
47
+
48
+ test("rapid double-toggle does not wedge the panel", async ({ page }) => {
49
+ // open then immediately close before the row exit animation finishes,
50
+ // then open again - the panel must still appear
51
+ await createPlaylistViaUI(page);
52
+ await addSongs(page, 5);
53
+
54
+ await page.getByTestId("btn-edit-playlist").click();
55
+ // close mid-animation
56
+ await page.getByTestId("btn-edit-playlist").click();
57
+ // re-open
58
+ await page.getByTestId("btn-edit-playlist").click();
59
+
60
+ await expect(panel(page).first()).toBeAttached({ timeout: 3000 });
61
+ });
62
+
63
+ test("escape closes the edit panel", async ({ page }) => {
64
+ await createPlaylistViaUI(page);
65
+ await page.getByTestId("btn-edit-playlist").click();
66
+ await expect(panel(page).first()).toBeAttached({ timeout: 3000 });
67
+
68
+ await page.keyboard.press("Escape");
69
+ await expect(panel(page)).toHaveCount(0, { timeout: 3000 });
70
+ });
71
+
72
+ test("song edit panel opens from a row and shows the title", async ({
73
+ page,
74
+ }) => {
75
+ await createPlaylistViaUI(page);
76
+ await addSongs(page, 3);
77
+
78
+ // rows expose an edit button on hover
79
+ const row = page.getByText("song-00");
80
+ await row.hover();
81
+ await page.locator("button[title='edit song']").first().click();
82
+
83
+ // the song edit panel shows the title input prefilled
84
+ await expect(
85
+ page.locator("input[value='song-00'], input[placeholder*='title']").first()
86
+ ).toBeVisible();
87
+ });
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ // generates committed test fixture files for e2e tests.
3
+ //
4
+ // requires: ffmpeg and imagemagick (magick).
5
+ // brew install ffmpeg imagemagick
6
+ //
7
+ // audio:
8
+ // tone-440hz-2s.wav A4 sine 2s, no tags (basic add/play)
9
+ // tone-880hz-5s.wav A5 sine 5s, no tags (seek / position)
10
+ // tone-220hz-10s.wav A3 sine 10s, no tags (prefetch window)
11
+ // chord-stack-3s.wav A maj chord 3s, no tags (multi-partial wav)
12
+ // tone-stereo-3s.wav stereo A4 3s (stereo channel)
13
+ // tagged-c5-3s.{mp3,m4a,ogg} C5 tone + full tags (tag parsing x3 formats)
14
+ // tagged-a3-4s.{mp3,m4a,ogg} A3 tone + full tags (second artist)
15
+ // tagged-f4-6s.{mp3,m4a,ogg} F4 tone + full tags (third artist/album)
16
+ // bare-glitch-1s.{mp3,m4a,ogg} 1s, no tags (very short, tagless)
17
+ // noisy-binaural-8s.mp3 stereo low drone 8s (stereo mp3)
18
+ // long-drone-90s.mp3 sub-bass 90s (prefetch budget)
19
+ //
20
+ // images:
21
+ // cover-red.png 128x128 flat red
22
+ // cover-blue.png 128x128 flat blue
23
+ // cover-checkers.png 64x64 checkerboard
24
+ // cover-noise.png 256x256 deterministic rgb noise
25
+ // cover-gradient.jpg 400x400 gradient
26
+ // cover-portrait.jpg 300x500 portrait (non-square)
27
+ // cover-thumb.jpg 48x48 tiny thumbnail
28
+ // cover-plasma.webp 256x256 plasma
29
+ // cover-wide.webp 600x200 landscape banner
30
+ // cover-anim.gif 80x80 animated 4-frame colour cycle
31
+ // cover-mono.gif 120x120 grayscale radial (non-animated)
32
+ //
33
+ // run once and commit:
34
+ // node e2e/fixtures/generate.mjs
35
+
36
+ import { writeFileSync, statSync, existsSync } from "node:fs";
37
+ import { execFileSync } from "node:child_process";
38
+ import { deflateSync, crc32 } from "node:zlib";
39
+ import { join, dirname } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import { tmpdir } from "node:os";
42
+ import { randomBytes } from "node:crypto";
43
+
44
+ const OUT = dirname(fileURLToPath(import.meta.url));
45
+
46
+ // --- helpers ---
47
+
48
+ function run(cmd, args) {
49
+ try {
50
+ execFileSync(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
51
+ return true;
52
+ } catch (e) {
53
+ const msg = e.stderr?.toString().slice(-300) ?? e.message;
54
+ console.error(` [error] ${cmd} ${args.slice(0, 5).join(" ")} ...\n ${msg.trim()}`);
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function tmp(ext) {
60
+ return join(tmpdir(), `fix-gen-${randomBytes(4).toString("hex")}${ext}`);
61
+ }
62
+
63
+ function emit(name) {
64
+ const p = join(OUT, name);
65
+ const kb = existsSync(p) ? (statSync(p).size / 1024).toFixed(1) : "?";
66
+ console.log(` ${name} (${kb} kB)`);
67
+ }
68
+
69
+ function write(name, data) {
70
+ writeFileSync(join(OUT, name), data);
71
+ emit(name);
72
+ }
73
+
74
+ // --- wav (pure Node) ---
75
+
76
+ function makeWav(durationSec, freqHz = 440, channels = 1) {
77
+ const sampleRate = 22050;
78
+ const numSamples = Math.floor(sampleRate * durationSec);
79
+ const dataSize = numSamples * 2 * channels;
80
+ const buf = Buffer.alloc(44 + dataSize);
81
+ buf.write("RIFF", 0, "ascii");
82
+ buf.writeUInt32LE(36 + dataSize, 4);
83
+ buf.write("WAVE", 8, "ascii");
84
+ buf.write("fmt ", 12, "ascii");
85
+ buf.writeUInt32LE(16, 16);
86
+ buf.writeUInt16LE(1, 20); // PCM
87
+ buf.writeUInt16LE(channels, 22);
88
+ buf.writeUInt32LE(sampleRate, 24);
89
+ buf.writeUInt32LE(sampleRate * 2 * channels, 28);
90
+ buf.writeUInt16LE(2 * channels, 32);
91
+ buf.writeUInt16LE(16, 34);
92
+ buf.write("data", 36, "ascii");
93
+ buf.writeUInt32LE(dataSize, 40);
94
+ for (let i = 0; i < numSamples; i++) {
95
+ for (let ch = 0; ch < channels; ch++) {
96
+ const f = freqHz * (1 + ch * 0.007); // slight detune per channel
97
+ const v = Math.sin((2 * Math.PI * f * i) / sampleRate);
98
+ buf.writeInt16LE(Math.floor(v * 0x4fff), 44 + (i * channels + ch) * 2);
99
+ }
100
+ }
101
+ return buf;
102
+ }
103
+
104
+ // additive chord: partials = [[freq, amplitude], ...]
105
+ function makeChordWav(durationSec, partials) {
106
+ const sampleRate = 22050;
107
+ const numSamples = Math.floor(sampleRate * durationSec);
108
+ const dataSize = numSamples * 2;
109
+ const buf = Buffer.alloc(44 + dataSize);
110
+ buf.write("RIFF", 0, "ascii");
111
+ buf.writeUInt32LE(36 + dataSize, 4);
112
+ buf.write("WAVE", 8, "ascii");
113
+ buf.write("fmt ", 12, "ascii");
114
+ buf.writeUInt32LE(16, 16);
115
+ buf.writeUInt16LE(1, 20);
116
+ buf.writeUInt16LE(1, 22);
117
+ buf.writeUInt32LE(sampleRate, 24);
118
+ buf.writeUInt32LE(sampleRate * 2, 28);
119
+ buf.writeUInt16LE(2, 32);
120
+ buf.writeUInt16LE(16, 34);
121
+ buf.write("data", 36, "ascii");
122
+ buf.writeUInt32LE(dataSize, 40);
123
+ const totalAmp = partials.reduce((s, [, a]) => s + a, 0);
124
+ for (let i = 0; i < numSamples; i++) {
125
+ let v = 0;
126
+ for (const [f, a] of partials) {
127
+ v += (a / totalAmp) * Math.sin((2 * Math.PI * f * i) / sampleRate);
128
+ }
129
+ buf.writeInt16LE(Math.floor(v * 0x4fff), 44 + i * 2);
130
+ }
131
+ return buf;
132
+ }
133
+
134
+ // --- png (pure Node, no external deps) ---
135
+
136
+ function pngChunk(type, data) {
137
+ const typeBuf = Buffer.from(type, "ascii");
138
+ const lenBuf = Buffer.alloc(4);
139
+ lenBuf.writeUInt32BE(data.length);
140
+ const crcVal = crc32(data, crc32(typeBuf));
141
+ const crcBuf = Buffer.alloc(4);
142
+ crcBuf.writeUInt32BE(crcVal >>> 0);
143
+ return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
144
+ }
145
+
146
+ const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
147
+
148
+ // pixelFn(x, y) -> [r, g, b]
149
+ function makePng(width, height, pixelFn) {
150
+ const ihdr = Buffer.alloc(13);
151
+ ihdr.writeUInt32BE(width, 0);
152
+ ihdr.writeUInt32BE(height, 4);
153
+ ihdr[8] = 8; // bit depth
154
+ ihdr[9] = 2; // truecolor RGB
155
+ const scanlineLen = 1 + width * 3;
156
+ const raw = Buffer.alloc(height * scanlineLen);
157
+ for (let y = 0; y < height; y++) {
158
+ raw[y * scanlineLen] = 0; // filter type: None
159
+ for (let x = 0; x < width; x++) {
160
+ const [r, g, b] = pixelFn(x, y);
161
+ const off = y * scanlineLen + 1 + x * 3;
162
+ raw[off] = r; raw[off + 1] = g; raw[off + 2] = b;
163
+ }
164
+ }
165
+ return Buffer.concat([
166
+ PNG_SIG,
167
+ pngChunk("IHDR", ihdr),
168
+ pngChunk("IDAT", deflateSync(raw)),
169
+ pngChunk("IEND", Buffer.alloc(0)),
170
+ ]);
171
+ }
172
+
173
+ // --- ffmpeg helpers ---
174
+
175
+ function metaFlags(title, artist, album, track) {
176
+ return [
177
+ "-metadata", `title=${title}`,
178
+ "-metadata", `artist=${artist}`,
179
+ "-metadata", `album=${album}`,
180
+ "-metadata", `track=${track}`,
181
+ "-metadata", "date=2024",
182
+ "-metadata", "genre=Electronic",
183
+ "-metadata", "comment=e2e fixture",
184
+ ];
185
+ }
186
+
187
+ // transcode a wav Buffer to mp3 + m4a + ogg (opus) with optional metadata
188
+ function encodeFormats(wavBuf, stem, extraFlags = []) {
189
+ const wavPath = tmp(".wav");
190
+ writeFileSync(wavPath, wavBuf);
191
+ if (run("ffmpeg", ["-y", "-i", wavPath, "-ar", "44100", "-ac", "1", "-q:a", "5", ...extraFlags, join(OUT, `${stem}.mp3`)])) emit(`${stem}.mp3`);
192
+ if (run("ffmpeg", ["-y", "-i", wavPath, "-ar", "44100", "-ac", "1", "-c:a", "aac", "-b:a", "96k", ...extraFlags, join(OUT, `${stem}.m4a`)])) emit(`${stem}.m4a`);
193
+ // libvorbis not available in this ffmpeg build; use libopus in ogg container
194
+ if (run("ffmpeg", ["-y", "-i", wavPath, "-ar", "48000", "-ac", "1", "-c:a", "libopus", "-b:a", "64k", ...extraFlags, join(OUT, `${stem}.ogg`)])) emit(`${stem}.ogg`);
195
+ }
196
+
197
+ // ===================== generate =====================
198
+
199
+ console.log("\naudio (wav, pure node):");
200
+ write("tone-440hz-2s.wav", makeWav(2, 440));
201
+ write("tone-880hz-5s.wav", makeWav(5, 880));
202
+ write("tone-220hz-10s.wav", makeWav(10, 220));
203
+ write("chord-stack-3s.wav", makeChordWav(3, [[440, 1], [659.25, 0.8], [554.37, 0.7]])); // A major: A+E+C#
204
+ write("tone-stereo-3s.wav", makeWav(3, 440, 2));
205
+
206
+ console.log("\naudio (encoded, ffmpeg):");
207
+
208
+ encodeFormats(makeWav(3, 523.25), "tagged-c5-3s", metaFlags("C5 Test Tone", "Fixture Bot", "Test Album", "1"));
209
+ encodeFormats(makeWav(4, 220), "tagged-a3-4s", metaFlags("A3 Low Tone", "Fixture Bot", "Test Album", "2"));
210
+ encodeFormats(makeWav(6, 349.23), "tagged-f4-6s", metaFlags("F4 Mid Tone", "Another Artist", "Second Album", "1"));
211
+ encodeFormats(makeWav(1, 600), "bare-glitch-1s"); // no tags, edge case
212
+
213
+ {
214
+ const p = tmp(".wav");
215
+ writeFileSync(p, makeWav(8, 80, 2));
216
+ if (run("ffmpeg", ["-y", "-i", p, "-ar", "44100", "-ac", "2", "-q:a", "6",
217
+ "-metadata", "title=Binaural Noise", "-metadata", "artist=Fixture Bot",
218
+ join(OUT, "noisy-binaural-8s.mp3")])) emit("noisy-binaural-8s.mp3");
219
+ }
220
+
221
+ {
222
+ const p = tmp(".wav");
223
+ writeFileSync(p, makeWav(90, 55));
224
+ if (run("ffmpeg", ["-y", "-i", p, "-ar", "22050", "-ac", "1", "-q:a", "9",
225
+ "-metadata", "title=Long Drone", "-metadata", "artist=Fixture Bot",
226
+ join(OUT, "long-drone-90s.mp3")])) emit("long-drone-90s.mp3");
227
+ }
228
+
229
+ console.log("\nimages (png, pure node):");
230
+ write("cover-red.png", makePng(128, 128, () => [200, 40, 40]));
231
+ write("cover-blue.png", makePng(128, 128, () => [40, 80, 200]));
232
+ write("cover-checkers.png", makePng(64, 64, (x, y) => (Math.floor(x / 8) + Math.floor(y / 8)) % 2 === 0 ? [220, 220, 220] : [30, 30, 30]));
233
+ write("cover-noise.png", makePng(256, 256, (x, y) => {
234
+ const h = (n) => ((n * 1664525 + 1013904223) >>> 0) % 256;
235
+ return [h(x * 256 + y), h(x * 256 + y + 65536), h(x * 256 + y + 131072)];
236
+ }));
237
+
238
+ console.log("\nimages (jpg/webp/gif, imagemagick):");
239
+
240
+ run("magick", ["-size", "400x400", "gradient:#c83232-#3264c8", "-quality", "85", join(OUT, "cover-gradient.jpg")]) && emit("cover-gradient.jpg");
241
+ run("magick", ["-size", "300x500", "gradient:#20c820-#c820c8", "-quality", "85", join(OUT, "cover-portrait.jpg")]) && emit("cover-portrait.jpg");
242
+ run("magick", ["-size", "48x48", "xc:#ff8800", "-quality", "75", join(OUT, "cover-thumb.jpg")]) && emit("cover-thumb.jpg");
243
+ run("magick", ["-size", "256x256", "plasma:", "-quality", "80", join(OUT, "cover-plasma.webp")]) && emit("cover-plasma.webp");
244
+ run("magick", ["-size", "600x200", "gradient:#1a1a2e-#e94560", "-quality", "80", join(OUT, "cover-wide.webp")]) && emit("cover-wide.webp");
245
+
246
+ run("magick", [
247
+ "-delay", "25", "-loop", "0",
248
+ "(", "-size", "80x80", "xc:#ff4444", ")",
249
+ "(", "-size", "80x80", "xc:#44ff44", ")",
250
+ "(", "-size", "80x80", "xc:#4444ff", ")",
251
+ "(", "-size", "80x80", "xc:#ffff44", ")",
252
+ join(OUT, "cover-anim.gif"),
253
+ ]) && emit("cover-anim.gif");
254
+
255
+ run("magick", ["-size", "120x120", "radial-gradient:white-black", join(OUT, "cover-mono.gif")]) && emit("cover-mono.gif");
256
+
257
+ console.log(`\ndone - output: ${OUT}`);
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ put your own audio files or images in this dir and e2e will try to use them!