@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,98 @@
1
+ import { render } from "solid-js/web";
2
+ import { Playlistz } from "./components/index.js";
3
+ import { FreqholePlaylistzSchema } from "./utils/standaloneTemplates.js";
4
+ import { initializeStandalonePlaylist } from "./services/standaloneService.js";
5
+ import "./styles.css";
6
+
7
+ // expose a reset helper on window so devs can clear all playlistz state from
8
+ // the browser console: await window.__playlistzReset()
9
+ if (typeof window !== "undefined") {
10
+ (window as unknown as Record<string, unknown>).__playlistzReset =
11
+ async () => {
12
+ // tell the service worker to clear its caches
13
+ if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
14
+ navigator.serviceWorker.controller.postMessage({
15
+ type: "PLAYLISTZ_RESET",
16
+ });
17
+ }
18
+ // clear all playlistz IDB databases
19
+ const dbs = (await indexedDB.databases?.()) ?? [];
20
+ await Promise.all(
21
+ dbs
22
+ .filter((d) => d.name)
23
+ .map(
24
+ (d) =>
25
+ new Promise<void>((res, rej) => {
26
+ const req = indexedDB.deleteDatabase(d.name!);
27
+ req.onsuccess = () => res();
28
+ req.onerror = () => rej(req.error);
29
+ })
30
+ )
31
+ );
32
+ // clear all caches
33
+ if ("caches" in window) {
34
+ const names = await caches.keys();
35
+ await Promise.all(names.map((n) => caches.delete(n)));
36
+ }
37
+ console.log(
38
+ "playlistz: all caches and IDB databases cleared. reload to restart fresh."
39
+ );
40
+ };
41
+ }
42
+
43
+ customElements.define(
44
+ "freqhole-playlistz",
45
+ class extends HTMLElement {
46
+ static get observedAttributes() {
47
+ return ["data-playlistz"];
48
+ }
49
+
50
+ connectedCallback() {
51
+ render(() => <Playlistz />, this);
52
+
53
+ const attr = this.getAttribute("data-playlistz");
54
+ if (attr) {
55
+ this._initFromJson(attr);
56
+ }
57
+ }
58
+
59
+ // fired when data-playlistz is set after the element is already connected
60
+ // (e.g. dynamically injected or set by a script that runs after registration)
61
+ attributeChangedCallback(
62
+ name: string,
63
+ _old: string | null,
64
+ val: string | null
65
+ ) {
66
+ if (name === "data-playlistz" && val) {
67
+ this._initFromJson(val);
68
+ }
69
+ }
70
+
71
+ private _initFromJson(json: string) {
72
+ try {
73
+ const parsed = JSON.parse(json);
74
+ const result = FreqholePlaylistzSchema.safeParse(parsed);
75
+ if (!result.success) {
76
+ console.error("invalid data-playlistz attribute:", result.error);
77
+ return;
78
+ }
79
+ this._initFromEntries(result.data);
80
+ } catch (err) {
81
+ console.error("failed to parse data-playlistz attribute:", err);
82
+ }
83
+ }
84
+
85
+ private _initFromEntries(
86
+ entries: ReturnType<typeof FreqholePlaylistzSchema.parse>
87
+ ) {
88
+ for (const entry of entries) {
89
+ initializeStandalonePlaylist(entry, {
90
+ setSelectedPlaylist: () => {},
91
+ setPlaylistSongs: () => {},
92
+ setSidebarCollapsed: () => {},
93
+ setError: (err) => console.error("standalone init error:", err),
94
+ }).catch((err) => console.error("standalone init failed:", err));
95
+ }
96
+ }
97
+ }
98
+ );
@@ -0,0 +1,7 @@
1
+ export { buildPlaylistZip, cleanupOpfsTempFile } from "./zipBuilder.js";
2
+ export type {
3
+ BlobFetcher,
4
+ PlaylistZipEntry,
5
+ PlaylistZipSong,
6
+ PlaylistZipOptions,
7
+ } from "./types.js";
@@ -0,0 +1,45 @@
1
+ // m3u8 generator for the zip bundle.
2
+ // takes pre-computed file paths rather than raw song/playlist types,
3
+ // so it works in both playlistz and spume contexts.
4
+
5
+ export interface M3UZipSong {
6
+ title: string;
7
+ artist: string;
8
+ album: string;
9
+ duration: number;
10
+ audioPath: string; // relative path written into the m3u8, e.g. "data/song.mp3"
11
+ imagePath?: string; // relative path for cover, e.g. "data/song-cover.jpg"
12
+ }
13
+
14
+ export interface M3UZipPlaylist {
15
+ id: string;
16
+ title: string;
17
+ description?: string;
18
+ rev?: number;
19
+ imagePath?: string; // relative path for playlist cover
20
+ }
21
+
22
+ export function generateM3UContent(
23
+ playlist: M3UZipPlaylist,
24
+ songs: M3UZipSong[],
25
+ ): string {
26
+ let out = "#EXTM3U\n";
27
+ out += `# Playlist: ${playlist.title}\n`;
28
+ out += `# PlaylistId: ${playlist.id}\n`;
29
+ out += `# PlaylistRev: ${playlist.rev ?? 0}\n`;
30
+ if (playlist.description) out += `# Description: ${playlist.description}\n`;
31
+ if (playlist.imagePath) out += `# PlaylistImage: ${playlist.imagePath}\n`;
32
+ out += "\n";
33
+
34
+ for (const song of songs) {
35
+ const duration = Math.round(song.duration);
36
+ out += `#EXTINF:${duration}, ${song.artist} - ${song.title}\n`;
37
+ out += `# Title: ${song.title}\n`;
38
+ out += `# Artist: ${song.artist}\n`;
39
+ out += `# Album: ${song.album}\n`;
40
+ if (song.imagePath) out += `# Image: ${song.imagePath}\n`;
41
+ out += `${song.audioPath}\n\n`;
42
+ }
43
+
44
+ return out;
45
+ }
@@ -0,0 +1,50 @@
1
+ // types for the zip-bundle export.
2
+ // no playlistz-internal type dependencies - safe to import from spume or node.
3
+
4
+ // key is sha256 hex (64 chars). both playlistz (IDB) and spume (Song.sha256)
5
+ // carry this. callers close over whatever mapping turns sha256 into bytes.
6
+ export type BlobFetcher = (sha256: string) => Promise<ArrayBuffer | undefined>;
7
+
8
+ export interface PlaylistZipSong {
9
+ id: string;
10
+ title: string;
11
+ artist?: string;
12
+ album?: string;
13
+ duration: number;
14
+ originalFilename: string;
15
+ mimeType: string;
16
+ sha?: string; // audio blob sha256 - BlobFetcher key
17
+ imageSha?: string; // cover image sha256 - BlobFetcher key
18
+ imageType?: string; // mime type of cover image
19
+ fileSize?: number;
20
+ lyrics?: string;
21
+ }
22
+
23
+ export interface PlaylistZipEntry {
24
+ playlist: {
25
+ id: string;
26
+ title: string;
27
+ description?: string;
28
+ rev?: number;
29
+ imageSha?: string; // sha256 of playlist cover image blob
30
+ imageType?: string; // mime type of playlist cover image
31
+ bgFilterEnabled?: boolean;
32
+ bgFilterBlur?: number;
33
+ bgFilterContrast?: number;
34
+ bgFilterBrightness?: number;
35
+ coverFilterEnabled?: boolean;
36
+ coverFilterBlur?: number;
37
+ };
38
+ songs: PlaylistZipSong[];
39
+ }
40
+
41
+ export interface PlaylistZipOptions {
42
+ includeImages?: boolean;
43
+ generateM3U?: boolean;
44
+ includeHTML?: boolean;
45
+ // explicit url to fetch freqhole-playlistz.js for embedding.
46
+ // when omitted in a browser context falls back to
47
+ // window.location.origin + "/freqhole-playlistz.js".
48
+ // set to null to skip embedding the app bundle entirely.
49
+ appBundleUrl?: string | null;
50
+ }
@@ -0,0 +1,33 @@
1
+ // shared filename/mime utilities used by both the zip builder
2
+ // and the m3u generator. no external dependencies.
3
+
4
+ export function sanitizeFilename(name: string): string {
5
+ return name
6
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
7
+ .replace(/\s+/g, "_")
8
+ .substring(0, 200);
9
+ }
10
+
11
+ export function createSafeTitle(title: string): string {
12
+ return title
13
+ .replace(/[<>:"/\\|?*]/g, "_")
14
+ .replace(/\s+/g, "_")
15
+ .toLowerCase()
16
+ .substring(0, 100);
17
+ }
18
+
19
+ export function getFileExtension(mimeType: string): string {
20
+ const map: Record<string, string> = {
21
+ "audio/mpeg": ".mp3",
22
+ "audio/mp4": ".m4a",
23
+ "audio/wav": ".wav",
24
+ "audio/flac": ".flac",
25
+ "audio/ogg": ".ogg",
26
+ "audio/webm": ".webm",
27
+ "image/jpeg": ".jpg",
28
+ "image/png": ".png",
29
+ "image/gif": ".gif",
30
+ "image/webp": ".webp",
31
+ };
32
+ return map[mimeType] ?? ".bin";
33
+ }
@@ -0,0 +1,309 @@
1
+ import { Zip, ZipPassThrough } from "fflate";
2
+ import type { BlobFetcher, PlaylistZipEntry, PlaylistZipOptions } from "./types.js";
3
+ import { sanitizeFilename, createSafeTitle, getFileExtension } from "./utils.js";
4
+ import { generateM3UContent } from "./m3u.js";
5
+ import { generateIndexHtml, generatePlaylistzJs } from "../utils/standaloneTemplates.js";
6
+ import { generateSwJs } from "../utils/swTemplate.js";
7
+
8
+ // derive a MIME type from a file path extension.
9
+ // used as a fallback when the caller didn't supply an explicit imageType.
10
+ function mimeFromPath(filePath: string): string {
11
+ const ext = filePath.split(".").pop()?.toLowerCase();
12
+ const map: Record<string, string> = {
13
+ jpg: "image/jpeg",
14
+ jpeg: "image/jpeg",
15
+ png: "image/png",
16
+ gif: "image/gif",
17
+ webp: "image/webp",
18
+ avif: "image/avif",
19
+ };
20
+ return (ext && map[ext]) ? map[ext]! : "image/jpeg";
21
+ }
22
+
23
+ const TEXT_ENC = new TextEncoder();
24
+
25
+ // creates a streaming zip builder.
26
+ //
27
+ // in browsers (OPFS available): each file is written to an OPFS temp file
28
+ // as its bytes are pushed. awaiting addFile() between files lets the OS
29
+ // flush the write queue, so the prior file's bytes become eligible for GC
30
+ // before the next file is fetched. this keeps peak memory at ~1 song at a time.
31
+ //
32
+ // in node/cli (no OPFS): chunks are accumulated in memory. for the cli path
33
+ // playlists are usually small so this is acceptable.
34
+ async function createStreamingZip(): Promise<{
35
+ addFile: (path: string, bytes: Uint8Array) => Promise<void>;
36
+ finish: () => Promise<Blob>;
37
+ tempName?: string;
38
+ }> {
39
+ const opfsAvailable =
40
+ typeof navigator !== "undefined" &&
41
+ typeof navigator.storage?.getDirectory === "function" &&
42
+ // tauri webviews expose navigator.storage but lack FileSystemWritableFileStream;
43
+ // skip the OPFS attempt entirely and use the in-memory path + tauri IPC instead.
44
+ !(typeof window !== "undefined" && "__TAURI_INTERNALS__" in window);
45
+
46
+ if (opfsAvailable) {
47
+ try {
48
+ const opfsRoot = await navigator.storage.getDirectory();
49
+ const tempName = `playlistz-dl-${Date.now()}.zip`;
50
+ const tempHandle = await opfsRoot.getFileHandle(tempName, { create: true });
51
+ const writable = await tempHandle.createWritable();
52
+
53
+ // chain writes so they execute in order and we can await the tail
54
+ let pendingWrite: Promise<unknown> = Promise.resolve();
55
+ let resolveFinished!: () => void;
56
+ let rejectFinished!: (err: unknown) => void;
57
+ const finished = new Promise<void>((res, rej) => {
58
+ resolveFinished = res;
59
+ rejectFinished = rej;
60
+ });
61
+
62
+ const zip = new Zip((err, data, final) => {
63
+ if (err) { rejectFinished(err); return; }
64
+ pendingWrite = pendingWrite.then(() => writable.write(data));
65
+ if (final) {
66
+ pendingWrite
67
+ .then(() => writable.close())
68
+ .then(resolveFinished, rejectFinished);
69
+ }
70
+ });
71
+
72
+ return {
73
+ tempName,
74
+ async addFile(path, bytes) {
75
+ const entry = new ZipPassThrough(path);
76
+ zip.add(entry);
77
+ entry.push(bytes, true);
78
+ // await the write tail so OPFS has consumed the bytes before the
79
+ // caller fetches the next file. this lets the prior ArrayBuffer GC.
80
+ await pendingWrite;
81
+ },
82
+ async finish() {
83
+ zip.end();
84
+ await finished;
85
+ return tempHandle.getFile();
86
+ },
87
+ };
88
+ } catch {
89
+ // OPFS unavailable or createWritable not supported (e.g. Tauri WKWebView) -
90
+ // fall through to the in-memory path below
91
+ }
92
+ }
93
+
94
+ // in-memory fallback (node/cli or when OPFS write is unavailable)
95
+ const chunks: Uint8Array[] = [];
96
+ let resolveDone!: () => void;
97
+ let rejectDone!: (err: unknown) => void;
98
+ const done = new Promise<void>((res, rej) => { resolveDone = res; rejectDone = rej; });
99
+
100
+ const zip = new Zip((err, data, final) => {
101
+ if (err) { rejectDone(err); return; }
102
+ chunks.push(data);
103
+ if (final) resolveDone();
104
+ });
105
+
106
+ return {
107
+ async addFile(path, bytes) {
108
+ const entry = new ZipPassThrough(path);
109
+ zip.add(entry);
110
+ entry.push(bytes, true);
111
+ },
112
+ async finish() {
113
+ zip.end();
114
+ await done;
115
+ const total = chunks.reduce((acc, c) => acc + c.length, 0);
116
+ const buf = new Uint8Array(total);
117
+ let off = 0;
118
+ for (const c of chunks) { buf.set(c, off); off += c.length; }
119
+ return new Blob([buf], { type: "application/zip" });
120
+ },
121
+ };
122
+ }
123
+
124
+ // builds a self-contained playlist zip as a Blob.
125
+ // each file is fetched and streamed into the zip one at a time so that
126
+ // prior audio bytes can be GC'd before the next song is fetched.
127
+ export async function buildPlaylistZip(
128
+ entry: PlaylistZipEntry,
129
+ fetchBlob: BlobFetcher,
130
+ options: PlaylistZipOptions = {},
131
+ ): Promise<Blob> {
132
+ const {
133
+ includeImages = true,
134
+ generateM3U = true,
135
+ includeHTML = true,
136
+ appBundleUrl,
137
+ } = options;
138
+
139
+ const rootName = createSafeTitle(entry.playlist.title) || "playlist";
140
+ const builder = await createStreamingZip();
141
+
142
+ // ---- playlist cover image ----
143
+ let playlistImagePath: string | undefined;
144
+ if (includeImages && entry.playlist.imageSha) {
145
+ const bytes = await fetchBlob(entry.playlist.imageSha);
146
+ if (bytes) {
147
+ const ext = getFileExtension(entry.playlist.imageType ?? "image/jpeg");
148
+ const filename = `playlist-cover${ext}`;
149
+ playlistImagePath = `data/${filename}`;
150
+ await builder.addFile(`${rootName}/${playlistImagePath}`, new Uint8Array(bytes));
151
+ // bytes GC-eligible after addFile resolves
152
+ }
153
+ }
154
+
155
+ // ---- songs: fetch, stream, and discard bytes one at a time ----
156
+ // resolvedSongs holds only metadata (no byte buffers) so it stays small.
157
+ const resolvedSongs: Array<{
158
+ song: PlaylistZipEntry["songs"][number];
159
+ audioPath: string;
160
+ imagePath?: string;
161
+ safeFilename: string;
162
+ fileSize: number;
163
+ }> = [];
164
+
165
+ for (const song of entry.songs) {
166
+ const safeFilename = song.originalFilename
167
+ ? sanitizeFilename(song.originalFilename)
168
+ : sanitizeFilename(`${song.title}.${getFileExtension(song.mimeType).slice(1)}`);
169
+ const safeBase = safeFilename.replace(/\.[^.]+$/, "");
170
+ const audioPath = `data/${safeFilename}`;
171
+ let fileSize = song.fileSize ?? 0;
172
+
173
+ // fetch audio and stream it immediately
174
+ if (song.sha) {
175
+ const audioBytes = await fetchBlob(song.sha);
176
+ if (audioBytes) {
177
+ fileSize = audioBytes.byteLength;
178
+ await builder.addFile(`${rootName}/${audioPath}`, new Uint8Array(audioBytes));
179
+ // audioBytes GC-eligible after addFile resolves
180
+ }
181
+ }
182
+
183
+ // fetch cover image and stream it immediately
184
+ let imagePath: string | undefined;
185
+ if (includeImages && song.imageSha) {
186
+ const imageBytes = await fetchBlob(song.imageSha);
187
+ if (imageBytes) {
188
+ const ext = getFileExtension(song.imageType ?? "image/jpeg");
189
+ const imageFilename = `${safeBase}-cover${ext}`;
190
+ imagePath = `data/${imageFilename}`;
191
+ await builder.addFile(`${rootName}/${imagePath}`, new Uint8Array(imageBytes));
192
+ // imageBytes GC-eligible after addFile resolves
193
+ }
194
+ }
195
+
196
+ resolvedSongs.push({ song, audioPath, imagePath, safeFilename, fileSize });
197
+ }
198
+
199
+ // ---- playlistz.js data file ----
200
+ const playlistzData = [
201
+ {
202
+ playlist: {
203
+ id: entry.playlist.id,
204
+ title: entry.playlist.title,
205
+ description: entry.playlist.description,
206
+ rev: entry.playlist.rev,
207
+ imageMimeType: entry.playlist.imageType ?? (playlistImagePath ? mimeFromPath(playlistImagePath) : undefined),
208
+ imageFilePath: playlistImagePath,
209
+ safeFilename: rootName,
210
+ bgFilterEnabled: entry.playlist.bgFilterEnabled,
211
+ bgFilterBlur: entry.playlist.bgFilterBlur,
212
+ bgFilterContrast: entry.playlist.bgFilterContrast,
213
+ bgFilterBrightness: entry.playlist.bgFilterBrightness,
214
+ coverFilterEnabled: entry.playlist.coverFilterEnabled,
215
+ coverFilterBlur: entry.playlist.coverFilterBlur,
216
+ },
217
+ songs: resolvedSongs.map((r) => ({
218
+ id: r.song.id,
219
+ title: r.song.title,
220
+ artist: r.song.artist ?? "",
221
+ album: r.song.album ?? "",
222
+ duration: r.song.duration,
223
+ originalFilename: r.song.originalFilename,
224
+ filePath: r.audioPath,
225
+ safeFilename: r.safeFilename,
226
+ fileSize: r.fileSize,
227
+ mimeType: r.song.mimeType,
228
+ sha: r.song.sha,
229
+ imageMimeType: r.song.imageType ?? (r.imagePath ? mimeFromPath(r.imagePath) : undefined),
230
+ imageFilePath: r.imagePath,
231
+ })),
232
+ },
233
+ ];
234
+ await builder.addFile(`${rootName}/playlistz.js`, TEXT_ENC.encode(generatePlaylistzJs(playlistzData)));
235
+
236
+ // ---- m3u8 file ----
237
+ if (generateM3U) {
238
+ const m3uContent = generateM3UContent(
239
+ {
240
+ id: entry.playlist.id,
241
+ title: entry.playlist.title,
242
+ description: entry.playlist.description,
243
+ rev: entry.playlist.rev,
244
+ imagePath: playlistImagePath,
245
+ },
246
+ resolvedSongs.map((r) => ({
247
+ title: r.song.title,
248
+ artist: r.song.artist ?? "",
249
+ album: r.song.album ?? "",
250
+ duration: r.song.duration,
251
+ audioPath: r.audioPath,
252
+ imagePath: r.imagePath,
253
+ })),
254
+ );
255
+ await builder.addFile(`${rootName}/data/${rootName}.m3u8`, TEXT_ENC.encode(m3uContent));
256
+ }
257
+
258
+ // ---- static shell files ----
259
+ if (includeHTML) {
260
+ await builder.addFile(`${rootName}/index.html`, TEXT_ENC.encode(generateIndexHtml()));
261
+ await builder.addFile(`${rootName}/sw.js`, TEXT_ENC.encode(generateSwJs()));
262
+
263
+ const bundleUrl =
264
+ appBundleUrl !== undefined
265
+ ? appBundleUrl
266
+ : typeof window !== "undefined"
267
+ ? `${window.location.origin}/freqhole-playlistz.js`
268
+ : null;
269
+
270
+ if (bundleUrl) {
271
+ try {
272
+ const res = await fetch(bundleUrl);
273
+ if (res.ok) {
274
+ const text = await res.text();
275
+ // vite dev server (and other html-first servers) return the app's
276
+ // index.html for unknown routes. detect and skip to avoid embedding
277
+ // html as the js bundle. users should run `npm run build:standalone`
278
+ // first so dist/freqhole-playlistz.js exists for the dev server to serve.
279
+ if (text.trimStart().startsWith("<!")) {
280
+ console.warn(
281
+ "freqhole-playlistz.js fetch returned HTML (vite dev mode?). " +
282
+ "run `npm run build:standalone` first, then retry the zip download.",
283
+ );
284
+ } else {
285
+ await builder.addFile(`${rootName}/freqhole-playlistz.js`, TEXT_ENC.encode(text));
286
+ }
287
+ } else {
288
+ console.warn("could not fetch freqhole-playlistz.js for zip bundle:", res.status);
289
+ }
290
+ } catch (err) {
291
+ console.warn("could not include freqhole-playlistz.js in zip:", err);
292
+ }
293
+ }
294
+ // note: no separate cli mjs needed - the cli is gated inside freqhole-playlistz.js
295
+ }
296
+
297
+ return builder.finish();
298
+ }
299
+
300
+ // delete an OPFS temp file created by buildPlaylistZip.
301
+ // call this after the browser download or tauri save is complete.
302
+ export async function cleanupOpfsTempFile(filename: string): Promise<void> {
303
+ try {
304
+ const root = await navigator.storage.getDirectory();
305
+ await root.removeEntry(filename);
306
+ } catch {
307
+ // file may have already been removed or never existed - ignore
308
+ }
309
+ }
@@ -0,0 +1,55 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,jsx,ts,tsx}",
6
+ ],
7
+
8
+ theme: {
9
+ extend: {
10
+ colors: {
11
+ magenta: {
12
+ 50: "#fdf4ff",
13
+ 100: "#fae8ff",
14
+ 200: "#f5d0fe",
15
+ 300: "#f0abfc",
16
+ 400: "#e879f9",
17
+ 500: "#d946ef", // Primary magenta
18
+ 600: "#c026d3",
19
+ 700: "#a21caf",
20
+ 800: "#86198f",
21
+ 900: "#701a75",
22
+ },
23
+ // Keep existing dark theme colors
24
+ dark: {
25
+ 50: "#f8f9fa",
26
+ 100: "#f1f3f4",
27
+ 200: "#e8eaed",
28
+ 300: "#dadce0",
29
+ 400: "#bdc1c6",
30
+ 500: "#9aa0a6",
31
+ 600: "#80868b",
32
+ 700: "#5f6368",
33
+ 800: "#3c4043",
34
+ 900: "#202124",
35
+ },
36
+ },
37
+ animation: {
38
+ slideInRight: "slideInRight 0.3s ease-out",
39
+ slideDown: "slideDown 0.3s ease-out",
40
+ pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
41
+ },
42
+ keyframes: {
43
+ slideInRight: {
44
+ from: { transform: "translateX(100%)" },
45
+ to: { transform: "translateX(0)" },
46
+ },
47
+ slideDown: {
48
+ from: { transform: "translateY(-100%)" },
49
+ to: { transform: "translateY(0)" },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ plugins: [],
55
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "resolveJsonModule": true,
8
+ "isolatedModules": true,
9
+ "noEmit": false,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "outDir": "./dist",
13
+ "removeComments": false,
14
+ "sourceMap": true,
15
+ "strict": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+ "exactOptionalPropertyTypes": false,
19
+ "noImplicitReturns": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+ "allowUnusedLabels": false,
24
+ "allowUnreachableCode": false,
25
+ "skipLibCheck": true,
26
+ "forceConsistentCasingInFileNames": true,
27
+ "allowSyntheticDefaultImports": true,
28
+ "esModuleInterop": true,
29
+ "experimentalDecorators": true,
30
+ "emitDecoratorMetadata": true,
31
+ "useDefineForClassFields": true,
32
+ "allowJs": false,
33
+ "checkJs": false,
34
+ "incremental": true,
35
+ "composite": false,
36
+ "tsBuildInfoFile": "./dist/.tsbuildinfo",
37
+ "jsx": "preserve",
38
+ "jsxImportSource": "solid-js",
39
+ "types": ["vite/client", "vitest/globals", "node"]
40
+ },
41
+ "include": ["src/**/*", "src/types/global.d.ts", "config/**/*", "e2e/**/*"],
42
+ "exclude": ["node_modules", "dist", "coverage"]
43
+ }