@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,114 @@
1
+ import js from "@eslint/js";
2
+ import tseslint from "@typescript-eslint/eslint-plugin";
3
+ import tsparser from "@typescript-eslint/parser";
4
+ import prettier from "eslint-plugin-prettier";
5
+ import prettierConfig from "eslint-config-prettier";
6
+ import solidPlugin from "eslint-plugin-solid";
7
+ import globals from "globals";
8
+
9
+ const baseConfig = {
10
+ languageOptions: {
11
+ parser: tsparser,
12
+ ecmaVersion: 2020,
13
+ sourceType: "module",
14
+ },
15
+ plugins: {
16
+ "@typescript-eslint": tseslint,
17
+ prettier: prettier,
18
+ },
19
+ rules: {
20
+ ...tseslint.configs.recommended.rules,
21
+ ...prettierConfig.rules,
22
+ "prettier/prettier": "error",
23
+ "@typescript-eslint/no-unused-vars": [
24
+ "error",
25
+ {
26
+ argsIgnorePattern: "^_",
27
+ varsIgnorePattern: "^_",
28
+ caughtErrorsIgnorePattern: "^_",
29
+ },
30
+ ],
31
+ "@typescript-eslint/explicit-function-return-type": "off",
32
+ "@typescript-eslint/no-explicit-any": "error",
33
+ "no-undef": "off",
34
+ "no-empty": ["error", { allowEmptyCatch: true }],
35
+ },
36
+ };
37
+
38
+ export default [
39
+ js.configs.recommended,
40
+
41
+ // TypeScript files
42
+ {
43
+ files: ["src/**/*.ts"],
44
+ ...baseConfig,
45
+ languageOptions: {
46
+ ...baseConfig.languageOptions,
47
+ globals: {
48
+ ...globals.browser,
49
+ ...globals.node,
50
+ vi: "readonly",
51
+ },
52
+ },
53
+ },
54
+
55
+ // TypeScript + JSX files
56
+ {
57
+ files: ["src/**/*.tsx"],
58
+ ...baseConfig,
59
+ languageOptions: {
60
+ ...baseConfig.languageOptions,
61
+ parserOptions: {
62
+ ecmaFeatures: { jsx: true },
63
+ },
64
+ globals: {
65
+ ...globals.browser,
66
+ vi: "readonly",
67
+ },
68
+ },
69
+ plugins: {
70
+ ...baseConfig.plugins,
71
+ solid: solidPlugin,
72
+ },
73
+ rules: {
74
+ ...baseConfig.rules,
75
+ ...solidPlugin.configs.recommended.rules,
76
+ },
77
+ },
78
+
79
+ // Test files
80
+ {
81
+ files: ["src/**/*.test.{ts,tsx}", "src/test-setup.ts"],
82
+ ...baseConfig,
83
+ languageOptions: {
84
+ ...baseConfig.languageOptions,
85
+ globals: {
86
+ ...globals.browser,
87
+ ...globals.node,
88
+ describe: "readonly",
89
+ it: "readonly",
90
+ expect: "readonly",
91
+ beforeAll: "readonly",
92
+ beforeEach: "readonly",
93
+ afterAll: "readonly",
94
+ afterEach: "readonly",
95
+ vi: "readonly",
96
+ global: "writable",
97
+ },
98
+ },
99
+ rules: {
100
+ ...baseConfig.rules,
101
+ "@typescript-eslint/no-explicit-any": "off", // Allow any in tests
102
+ },
103
+ },
104
+
105
+ // Build/config files
106
+ {
107
+ files: ["config/**/*.ts", "build-component.js"],
108
+ ...baseConfig,
109
+ languageOptions: {
110
+ ...baseConfig.languageOptions,
111
+ globals: globals.node,
112
+ },
113
+ },
114
+ ];
package/index.html ADDED
@@ -0,0 +1,54 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="apple-mobile-web-app-capable" content="yes" />
8
+ <meta
9
+ name="apple-mobile-web-app-status-bar-style"
10
+ content="black-translucent"
11
+ />
12
+ <meta name="mobile-web-app-capable" content="yes" />
13
+ <meta name="theme-color" content="#000000" />
14
+ <title>playlistz</title>
15
+ <style>
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+ body {
20
+ font-family:
21
+ system-ui,
22
+ -apple-system,
23
+ sans-serif;
24
+ font-size: 16px;
25
+ margin: 0;
26
+ padding: 0;
27
+ background-color: black;
28
+ color: white;
29
+ }
30
+ /* ensure proper text wrapping */
31
+ .break-words {
32
+ word-wrap: break-word;
33
+ word-break: break-word;
34
+ overflow-wrap: break-word;
35
+ hyphens: auto;
36
+ }
37
+ /* loading style */
38
+ .loading {
39
+ display: flex;
40
+ justify-content: center;
41
+ align-items: center;
42
+ height: 100vh;
43
+ font-size: 18px;
44
+ color: #d946ef;
45
+ }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <div id="root">
50
+ <div class="loading">loading playlistz...</div>
51
+ </div>
52
+ <script type="module" src="/src/index.tsx"></script>
53
+ </body>
54
+ </html>
package/package.json ADDED
@@ -0,0 +1,119 @@
1
+ {
2
+ "name": "@freqhole/playlistz",
3
+ "version": "0.0.1",
4
+ "description": "make some music playlistz in yr browser!",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/freqhole/playlistz"
9
+ },
10
+ "exports": {
11
+ ".": "./src/web-component.tsx",
12
+ "./zip-bundle": "./src/zip-bundle/index.ts",
13
+ "./templates": "./src/utils/standaloneTemplates.ts"
14
+ },
15
+ "typesVersions": {
16
+ "*": {
17
+ "zip-bundle": [
18
+ "./src/zip-bundle/index.ts"
19
+ ],
20
+ "templates": [
21
+ "./src/utils/standaloneTemplates.ts"
22
+ ]
23
+ }
24
+ },
25
+ "keywords": [
26
+ "music-player",
27
+ "playlist",
28
+ "solid-js",
29
+ "web-component",
30
+ "indexeddb",
31
+ "pwa"
32
+ ],
33
+ "author": "3dwardsharp",
34
+ "license": "MIT",
35
+ "bin": {
36
+ "freqhole-playlistz": "./dist/freqhole-playlistz.cli.mjs"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "start": "vite --config config/vite.config.ts",
43
+ "dev": "npm start",
44
+ "build": "npm run build:app && npm run build:standalone -- --no-clear && npm run build:zip-bundle-lib",
45
+ "build:standalone": "node build-component.js",
46
+ "build:app": "vite build --config config/vite.config.ts",
47
+ "build:zip-bundle-lib": "node build-zip-bundle-lib.js",
48
+ "preview": "vite preview --config config/vite.config.ts",
49
+ "test": "vitest --run --config config/vitest.config.ts",
50
+ "test:watch": "vitest --config config/vitest.config.ts",
51
+ "test:coverage": "vitest --coverage --run --config config/vitest.config.ts",
52
+ "test:e2e": "playwright test --config config/playwright.config.ts",
53
+ "test:e2e:mock": "playwright test --config config/playwright.config.ts --grep @mock",
54
+ "test:e2e:real": "playwright test --config config/playwright.config.ts --grep-invert \"@mock|@p2p\"",
55
+ "test:e2e:p2p": "playwright test --config config/playwright.config.ts --grep @p2p",
56
+ "lint": "eslint",
57
+ "lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
58
+ "format": "prettier --write 'src/**/*.{ts,tsx}'",
59
+ "type-check": "tsc --noEmit",
60
+ "typecheck": "tsc --noEmit",
61
+ "clean": "rm -rf dist coverage",
62
+ "changes": "changeset",
63
+ "changeset": "changeset",
64
+ "changeset:status": "changeset status",
65
+ "version": "changeset version",
66
+ "use-local": "node scripts/use-local.mjs",
67
+ "use-published": "node scripts/use-published.mjs"
68
+ },
69
+ "dependencies": {
70
+ "@automerge/automerge-repo": "^2.5.5",
71
+ "@automerge/automerge-repo-network-broadcastchannel": "^2.5.5",
72
+ "@automerge/automerge-repo-storage-indexeddb": "^2.5.5",
73
+ "@freqhole/api-client": "0.1.31",
74
+ "@freqhole/midden": "0.1.31",
75
+ "fflate": "^0.8.3",
76
+ "idb": "^8.0.3",
77
+ "jszip": "^3.10.1",
78
+ "solid-js": "^1.8.5",
79
+ "zod": "^4.4.3"
80
+ },
81
+ "devDependencies": {
82
+ "@changesets/cli": "^2.27.10",
83
+ "@eslint/js": "^9.34.0",
84
+ "@playwright/test": "^1.60.0",
85
+ "@solidjs/testing-library": "^0.8.10",
86
+ "@tailwindcss/vite": "^4.1.11",
87
+ "@types/jsdom": "^21.1.7",
88
+ "@types/jszip": "^3.4.0",
89
+ "@types/node": "^20.9.0",
90
+ "@typescript-eslint/eslint-plugin": "^8.41.0",
91
+ "@typescript-eslint/parser": "^8.41.0",
92
+ "@vitest/coverage-v8": "^1.6.1",
93
+ "eslint": "^9.29.0",
94
+ "eslint-config-prettier": "^9.1.2",
95
+ "eslint-plugin-prettier": "^5.5.4",
96
+ "eslint-plugin-solid": "^0.14.5",
97
+ "fake-indexeddb": "^6.0.1",
98
+ "globals": "^16.3.0",
99
+ "jsdom": "^26.1.0",
100
+ "prettier": "^3.1.0",
101
+ "solid-element": "^1.9.1",
102
+ "tailwindcss": "^4.1.11",
103
+ "typescript": "^5.3.2",
104
+ "vite": "^6.3.5",
105
+ "vite-plugin-solid": "^2.11.6",
106
+ "vite-plugin-top-level-await": "^1.6.0",
107
+ "vite-plugin-wasm": "^3.5.0",
108
+ "vitest": "^1.6.1",
109
+ "vitest-dom": "^0.1.1"
110
+ },
111
+ "prettier": {
112
+ "semi": true,
113
+ "trailingComma": "es5",
114
+ "singleQuote": false,
115
+ "printWidth": 80,
116
+ "tabWidth": 2,
117
+ "jsxSingleQuote": false
118
+ }
119
+ }
package/public/sw.js ADDED
@@ -0,0 +1,134 @@
1
+ // playlistz service worker
2
+ //
3
+ // caches the app shell files (freqhole-playlistz.js, playlistz.js, index.html, sw.js).
4
+ // audio and image files in data/ are NOT pre-cached; the app ui handles that separately.
5
+ //
6
+ // bumping CACHE_VERSION forces all clients to receive fresh app files on next load.
7
+ const CACHE_VERSION = "v2";
8
+ const CACHE_NAME = `playlistz-${CACHE_VERSION}`;
9
+
10
+ // app shell files to cache on install
11
+ const APP_SHELL = [
12
+ "freqhole-playlistz.js",
13
+ "playlistz.js",
14
+ "index.html",
15
+ "sw.js",
16
+ ];
17
+
18
+ // install: cache app shell and skip waiting so this sw activates immediately
19
+ self.addEventListener("install", (event) => {
20
+ event.waitUntil(
21
+ caches.open(CACHE_NAME).then((cache) =>
22
+ cache.addAll(APP_SHELL).catch((err) => {
23
+ // some files may not exist (e.g. playlistz.js before first generate run)
24
+ console.warn("playlistz sw: pre-cache partial failure:", err);
25
+ }),
26
+ ).then(() => self.skipWaiting()),
27
+ );
28
+ });
29
+
30
+ // activate: delete old caches, claim all clients immediately
31
+ self.addEventListener("activate", (event) => {
32
+ event.waitUntil(
33
+ caches.keys()
34
+ .then((names) =>
35
+ Promise.all(
36
+ names
37
+ .filter((n) => n.startsWith("playlistz-") && n !== CACHE_NAME)
38
+ .map((n) => caches.delete(n)),
39
+ ),
40
+ )
41
+ .then(() => self.clients.claim()),
42
+ );
43
+ });
44
+
45
+ // fetch: cache-first for app shell files, network-pass-through for everything else
46
+ self.addEventListener("fetch", (event) => {
47
+ const url = new URL(event.request.url);
48
+ const filename = url.pathname.split("/").pop() ?? "";
49
+ const isAppShell = APP_SHELL.includes(filename) || url.pathname === "/" || url.pathname === "";
50
+
51
+ if (!isAppShell) {
52
+ return; // let browser handle data/ assets (audio, images)
53
+ }
54
+
55
+ event.respondWith(
56
+ caches.match(event.request).then((cached) => {
57
+ if (cached) return cached;
58
+ return fetch(event.request).then((response) => {
59
+ if (response.ok) {
60
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, response.clone()));
61
+ }
62
+ return response;
63
+ });
64
+ }).catch(() => caches.match("index.html")),
65
+ );
66
+ });
67
+
68
+ // message: handle reset/clear from the page via window.__playlistzReset()
69
+ self.addEventListener("message", (event) => {
70
+ if (event.data?.type === "PLAYLISTZ_RESET") {
71
+ event.waitUntil(
72
+ caches.keys()
73
+ .then((names) => Promise.all(names.map((n) => caches.delete(n))))
74
+ .then(() => self.clients.matchAll())
75
+ .then((clients) => clients.forEach((c) => c.postMessage({ type: "PLAYLISTZ_RESET_DONE" }))),
76
+ );
77
+ }
78
+ });
79
+
80
+
81
+ self.addEventListener("install", (event) => {
82
+ console.log("playlistz service worker: installing...");
83
+ event.waitUntil(
84
+ caches
85
+ .open(CACHE_NAME)
86
+ .then((cache) => {
87
+ console.log("playlistz service worker: caching app shell");
88
+ return cache.addAll(urlsToCache);
89
+ })
90
+ .catch((error) => {
91
+ console.error(
92
+ "playlistz service worker: failed to cache app shell:",
93
+ error
94
+ );
95
+ })
96
+ );
97
+ });
98
+
99
+ self.addEventListener("fetch", (event) => {
100
+ event.respondWith(
101
+ caches
102
+ .match(event.request)
103
+ .then((response) => {
104
+ // return cached version or fetch from network
105
+ if (response) {
106
+ return response;
107
+ }
108
+ return fetch(event.request);
109
+ })
110
+ .catch((error) => {
111
+ console.error("playlistz service worker: fetch failed:", error);
112
+ throw error;
113
+ })
114
+ );
115
+ });
116
+
117
+ self.addEventListener("activate", (event) => {
118
+ console.log("playlistz service worker: activating...");
119
+ event.waitUntil(
120
+ caches.keys().then((cacheNames) => {
121
+ return Promise.all(
122
+ cacheNames.map((cacheName) => {
123
+ if (cacheName !== CACHE_NAME) {
124
+ console.log(
125
+ "playlistz service worker: deleting old cache:",
126
+ cacheName
127
+ );
128
+ return caches.delete(cacheName);
129
+ }
130
+ })
131
+ );
132
+ })
133
+ );
134
+ });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // switch @freqhole/* deps to local file: paths for development.
3
+ // run: npm run use-local && npm install
4
+ import { readFileSync, writeFileSync } from "fs";
5
+ import { resolve, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { execSync } from "child_process";
8
+
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const pkgPath = resolve(root, "package.json");
11
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
12
+
13
+ // paths are relative to the playlistz/ directory (adjust if your monorepo layout differs)
14
+ const LOCAL = {
15
+ "@freqhole/api-client": "file:../tomb/client-codegen/freqhole-api-client",
16
+ "@freqhole/midden": "file:../tomb/client/midden/pkg",
17
+ };
18
+
19
+ let changed = false;
20
+ for (const [name, localPath] of Object.entries(LOCAL)) {
21
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
22
+ if (pkg[section]?.[name] !== undefined && pkg[section][name] !== localPath) {
23
+ console.log(` ${name}: ${pkg[section][name]} -> ${localPath}`);
24
+ pkg[section][name] = localPath;
25
+ changed = true;
26
+ }
27
+ }
28
+ }
29
+
30
+ if (!changed) {
31
+ console.log("already using local paths - nothing to change");
32
+ process.exit(0);
33
+ }
34
+
35
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
36
+ console.log("\nwritten. running npm install...");
37
+ execSync("npm install", { cwd: root, stdio: "inherit" });
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // switch @freqhole/* deps back to published npm versions.
3
+ // run: npm run use-published && npm install
4
+ //
5
+ // the published versions are read from .freqhole-versions.json (git-tracked).
6
+ // update that file when you publish a new version.
7
+ import { readFileSync, writeFileSync } from "fs";
8
+ import { resolve, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { execSync } from "child_process";
11
+
12
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
13
+ const pkgPath = resolve(root, "package.json");
14
+ const versionsPath = resolve(root, ".freqhole-versions.json");
15
+
16
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
17
+ const versions = JSON.parse(readFileSync(versionsPath, "utf-8"));
18
+
19
+ let changed = false;
20
+ for (const [name, version] of Object.entries(versions)) {
21
+ for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
22
+ if (pkg[section]?.[name] !== undefined && pkg[section][name] !== version) {
23
+ console.log(` ${name}: ${pkg[section][name]} -> ${version}`);
24
+ pkg[section][name] = version;
25
+ changed = true;
26
+ }
27
+ }
28
+ }
29
+
30
+ if (!changed) {
31
+ console.log("already using published versions - nothing to change");
32
+ process.exit(0);
33
+ }
34
+
35
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
36
+ console.log("\nwritten. running npm install...");
37
+ execSync("npm install", { cwd: root, stdio: "inherit" });
package/src/App.tsx ADDED
@@ -0,0 +1,9 @@
1
+ import { Component } from "solid-js";
2
+ import { Playlistz } from "./components";
3
+ import "./styles.css";
4
+
5
+ const App: Component = () => {
6
+ return <Playlistz />;
7
+ };
8
+
9
+ export default App;
@@ -0,0 +1,164 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ function validateField(
5
+ obj: Record<string, unknown>,
6
+ field: string,
7
+ type: string,
8
+ required: boolean,
9
+ context: string,
10
+ errors: string[],
11
+ warnings: string[]
12
+ ): void {
13
+ const val = obj[field];
14
+ if (val === undefined || val === null) {
15
+ if (required) errors.push(`${context}: missing required field "${field}"`);
16
+ return;
17
+ }
18
+ if (typeof val !== type) {
19
+ (required ? errors : warnings).push(
20
+ `${context}: "${field}" should be ${type}, got ${typeof val}`
21
+ );
22
+ }
23
+ }
24
+
25
+ function checkData(
26
+ data: unknown,
27
+ baseDir: string
28
+ ): { errors: string[]; warnings: string[] } {
29
+ const errors: string[] = [];
30
+ const warnings: string[] = [];
31
+
32
+ if (!Array.isArray(data)) {
33
+ errors.push(`playlist data must be an array, got ${typeof data}`);
34
+ return { errors, warnings };
35
+ }
36
+
37
+ if (data.length === 0) {
38
+ warnings.push("playlist data is empty (no playlists)");
39
+ return { errors, warnings };
40
+ }
41
+
42
+ for (let i = 0; i < data.length; i++) {
43
+ const entry = data[i];
44
+ const ctx = `playlist[${i}]`;
45
+ if (typeof entry !== "object" || entry === null) {
46
+ errors.push(`${ctx}: must be an object`);
47
+ continue;
48
+ }
49
+ const e = entry as Record<string, unknown>;
50
+
51
+ // validate playlist header
52
+ if (typeof e["playlist"] !== "object" || e["playlist"] === null) {
53
+ errors.push(`${ctx}: missing "playlist" object`);
54
+ } else {
55
+ const p = e["playlist"] as Record<string, unknown>;
56
+ const pc = `${ctx}.playlist`;
57
+ validateField(p, "id", "string", true, pc, errors, warnings);
58
+ validateField(p, "title", "string", true, pc, errors, warnings);
59
+ validateField(p, "rev", "number", false, pc, errors, warnings);
60
+ validateField(p, "description", "string", false, pc, errors, warnings);
61
+
62
+ // check playlist cover image
63
+ if (p["imageExtension"]) {
64
+ const imgPath = path.join(baseDir, "data", `playlist-cover${p["imageExtension"]}`);
65
+ if (!fs.existsSync(imgPath)) {
66
+ warnings.push(`${pc}: cover image not found: data/playlist-cover${p["imageExtension"]}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ // validate songs
72
+ if (!Array.isArray(e["songs"])) {
73
+ errors.push(`${ctx}: "songs" must be an array`);
74
+ continue;
75
+ }
76
+
77
+ if (e["songs"].length === 0) {
78
+ warnings.push(`${ctx}: no songs`);
79
+ }
80
+
81
+ for (let j = 0; j < e["songs"].length; j++) {
82
+ const song = e["songs"][j];
83
+ const sc = `${ctx}.songs[${j}]`;
84
+ if (typeof song !== "object" || song === null) {
85
+ errors.push(`${sc}: must be an object`);
86
+ continue;
87
+ }
88
+ const s = song as Record<string, unknown>;
89
+ validateField(s, "id", "string", true, sc, errors, warnings);
90
+ validateField(s, "title", "string", true, sc, errors, warnings);
91
+ validateField(s, "artist", "string", true, sc, errors, warnings);
92
+ validateField(s, "album", "string", true, sc, errors, warnings);
93
+ validateField(s, "duration", "number", true, sc, errors, warnings);
94
+ validateField(s, "originalFilename", "string", true, sc, errors, warnings);
95
+ validateField(s, "fileSize", "number", true, sc, errors, warnings);
96
+ validateField(s, "sha", "string", false, sc, errors, warnings);
97
+
98
+ // check audio file (skip http/https)
99
+ const filename = (s["safeFilename"] ?? s["originalFilename"]) as string | undefined;
100
+ if (filename && !filename.startsWith("http://") && !filename.startsWith("https://")) {
101
+ const audioPath = path.join(baseDir, "data", filename);
102
+ if (!fs.existsSync(audioPath)) {
103
+ errors.push(`${sc}: audio file not found: data/${filename}`);
104
+ }
105
+ }
106
+
107
+ // check song cover image
108
+ if (s["imageExtension"] && typeof s["safeFilename"] === "string") {
109
+ const baseName = s["safeFilename"].replace(/\.[^.]+$/, "");
110
+ const imgFile = `${baseName}-cover${s["imageExtension"]}`;
111
+ if (!fs.existsSync(path.join(baseDir, "data", imgFile))) {
112
+ warnings.push(`${sc}: cover image not found: data/${imgFile}`);
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return { errors, warnings };
119
+ }
120
+
121
+ export function checkFile(filePath: string): void {
122
+ const resolved = path.resolve(filePath);
123
+ const baseDir = path.dirname(resolved);
124
+
125
+ if (!fs.existsSync(resolved)) {
126
+ console.error(`file not found: ${resolved}`);
127
+ process.exit(1);
128
+ }
129
+
130
+ let data: unknown;
131
+ try {
132
+ const src = fs.readFileSync(resolved, "utf-8");
133
+ const attrMatch = src.match(/setAttribute\s*\(\s*'data-playlistz'\s*,\s*("(?:[^"\\]|\\.)*")\s*\)/);
134
+ if (!attrMatch) {
135
+ console.error(`${filePath} does not set the data-playlistz attribute`);
136
+ process.exit(1);
137
+ }
138
+ data = JSON.parse(JSON.parse(attrMatch[1]!));
139
+ } catch (err) {
140
+ console.error(`failed to parse ${filePath}:`);
141
+ console.error(err instanceof Error ? err.message : String(err));
142
+ process.exit(1);
143
+ }
144
+
145
+ if (data === undefined) {
146
+ console.error(`${filePath} does not contain playlist data`);
147
+ process.exit(1);
148
+ }
149
+
150
+ const { errors, warnings } = checkData(data, baseDir);
151
+
152
+ if (warnings.length > 0) {
153
+ warnings.forEach((w) => console.warn(` warn ${w}`));
154
+ }
155
+ if (errors.length > 0) {
156
+ errors.forEach((e) => console.error(` error ${e}`));
157
+ console.error(`\n${errors.length} error(s) - ${filePath} is invalid`);
158
+ process.exit(1);
159
+ }
160
+
161
+ const playlists = data as Array<{ songs: unknown[] }>;
162
+ const totalSongs = playlists.reduce((n, p) => n + p.songs.length, 0);
163
+ console.log(`ok ${playlists.length} playlist(s), ${totalSongs} song(s)${warnings.length > 0 ? ` (${warnings.length} warning(s))` : ""}`);
164
+ }