@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
import type { Playlist } from "../types/playlist.js";
|
|
3
|
+
|
|
4
|
+
// Offline state signals
|
|
5
|
+
const [isOnline, setIsOnline] = createSignal(navigator.onLine);
|
|
6
|
+
const [serviceWorkerReady, setServiceWorkerReady] = createSignal(false);
|
|
7
|
+
const [persistentStorageGranted, setPersistentStorageGranted] =
|
|
8
|
+
createSignal(false);
|
|
9
|
+
|
|
10
|
+
// Export signals for components to use
|
|
11
|
+
export { isOnline, serviceWorkerReady, persistentStorageGranted };
|
|
12
|
+
|
|
13
|
+
const CACHE_NAME = "playlistz-cache-v1";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Request persistent storage
|
|
17
|
+
*/
|
|
18
|
+
async function requestPersistentStorage(): Promise<boolean> {
|
|
19
|
+
try {
|
|
20
|
+
if ("storage" in navigator && "persist" in navigator.storage) {
|
|
21
|
+
const granted = await navigator.storage.persist();
|
|
22
|
+
|
|
23
|
+
if (granted) {
|
|
24
|
+
setPersistentStorageGranted(true);
|
|
25
|
+
} else {
|
|
26
|
+
setPersistentStorageGranted(false);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return granted;
|
|
30
|
+
} else {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error("Error requesting persistent storage:", error);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate and register PWA manifest - simplified approach
|
|
41
|
+
*/
|
|
42
|
+
function generatePWAManifest(
|
|
43
|
+
playlistTitle?: string,
|
|
44
|
+
playlistImagePath?: string
|
|
45
|
+
): void {
|
|
46
|
+
const appName = playlistTitle || "playlistz";
|
|
47
|
+
|
|
48
|
+
// use playlist cover image if available, otherwise fallback to svg
|
|
49
|
+
let iconSrc;
|
|
50
|
+
let iconType;
|
|
51
|
+
if (playlistImagePath) {
|
|
52
|
+
iconSrc = playlistImagePath;
|
|
53
|
+
// determine type from file extension
|
|
54
|
+
if (playlistImagePath.endsWith(".png")) {
|
|
55
|
+
iconType = "image/png";
|
|
56
|
+
} else if (playlistImagePath.endsWith(".webp")) {
|
|
57
|
+
iconType = "image/webp";
|
|
58
|
+
} else {
|
|
59
|
+
iconType = "image/jpeg"; // default to jpeg
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
iconSrc = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192"><rect width="192" height="192" fill="#000000"/><text x="96" y="125" text-anchor="middle" font-size="100" font-family="Arial,sans-serif" font-weight="bold" fill="magenta">z</text></svg>')}`;
|
|
63
|
+
iconType = "image/svg+xml";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create a super simple manifest object
|
|
67
|
+
const manifest = {
|
|
68
|
+
name: appName,
|
|
69
|
+
short_name: appName.length > 12 ? appName.substring(0, 12) : appName,
|
|
70
|
+
description: "Offline music playlist manager",
|
|
71
|
+
start_url: "./",
|
|
72
|
+
display: "standalone",
|
|
73
|
+
background_color: "#000000",
|
|
74
|
+
theme_color: "#000000",
|
|
75
|
+
icons: [
|
|
76
|
+
{
|
|
77
|
+
src: iconSrc,
|
|
78
|
+
sizes: "192x192",
|
|
79
|
+
type: iconType,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Clear any existing manifest and apple-touch-icons for iOS refresh
|
|
85
|
+
const existingLink = document.querySelector('link[rel="manifest"]');
|
|
86
|
+
if (existingLink) {
|
|
87
|
+
existingLink.remove();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Force iOS to refresh by removing all apple-touch-icons
|
|
91
|
+
const existingAppleIcons = document.querySelectorAll(
|
|
92
|
+
'link[rel="apple-touch-icon"]'
|
|
93
|
+
);
|
|
94
|
+
existingAppleIcons.forEach((icon) => icon.remove());
|
|
95
|
+
|
|
96
|
+
// Create new manifest with cache busting
|
|
97
|
+
const manifestJSON = JSON.stringify(manifest);
|
|
98
|
+
const manifestBlob = new Blob([manifestJSON], {
|
|
99
|
+
type: "application/manifest+json",
|
|
100
|
+
});
|
|
101
|
+
const manifestURL = URL.createObjectURL(manifestBlob);
|
|
102
|
+
|
|
103
|
+
const link = document.createElement("link");
|
|
104
|
+
link.rel = "manifest";
|
|
105
|
+
// note: blob urls must not carry query strings - browsers treat the whole
|
|
106
|
+
// string as the lookup key and fail with ERR_FILE_NOT_FOUND
|
|
107
|
+
link.href = manifestURL;
|
|
108
|
+
document.head.appendChild(link);
|
|
109
|
+
|
|
110
|
+
// Add iOS meta tags
|
|
111
|
+
const metaTags = [
|
|
112
|
+
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
|
113
|
+
{
|
|
114
|
+
name: "apple-mobile-web-app-status-bar-style",
|
|
115
|
+
content: "black-translucent",
|
|
116
|
+
},
|
|
117
|
+
{ name: "apple-mobile-web-app-title", content: appName },
|
|
118
|
+
{ name: "theme-color", content: "#000000" },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
metaTags.forEach(({ name, content }) => {
|
|
122
|
+
let meta = document.querySelector(`meta[name="${name}"]`);
|
|
123
|
+
if (!meta) {
|
|
124
|
+
meta = document.createElement("meta");
|
|
125
|
+
meta.setAttribute("name", name);
|
|
126
|
+
document.head.appendChild(meta);
|
|
127
|
+
}
|
|
128
|
+
meta.setAttribute("content", content);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Add fresh apple-touch-icons for iOS (multiple sizes with cache busting)
|
|
132
|
+
const iconSizes = [
|
|
133
|
+
"57x57",
|
|
134
|
+
"60x60",
|
|
135
|
+
"72x72",
|
|
136
|
+
"76x76",
|
|
137
|
+
"114x114",
|
|
138
|
+
"120x120",
|
|
139
|
+
"144x144",
|
|
140
|
+
"152x152",
|
|
141
|
+
"180x180",
|
|
142
|
+
];
|
|
143
|
+
// use playlist image for apple touch icons if available
|
|
144
|
+
const appleIconSrc =
|
|
145
|
+
playlistImagePath ||
|
|
146
|
+
`data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180"><rect width="180" height="180" fill="#000000"/><text x="90" y="117" text-anchor="middle" font-size="94" font-family="Arial,sans-serif" font-weight="bold" fill="magenta">z</text></svg>')}`;
|
|
147
|
+
|
|
148
|
+
iconSizes.forEach((size) => {
|
|
149
|
+
const iconLink = document.createElement("link");
|
|
150
|
+
iconLink.rel = "apple-touch-icon";
|
|
151
|
+
iconLink.sizes = size;
|
|
152
|
+
iconLink.href = `${appleIconSrc}#${Date.now()}`;
|
|
153
|
+
document.head.appendChild(iconLink);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Default apple-touch-icon
|
|
157
|
+
const defaultIcon = document.createElement("link");
|
|
158
|
+
defaultIcon.rel = "apple-touch-icon";
|
|
159
|
+
defaultIcon.href = `${appleIconSrc}#${Date.now()}`;
|
|
160
|
+
document.head.appendChild(defaultIcon);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* helper function to generate playlist image path for pwa manifest
|
|
165
|
+
*/
|
|
166
|
+
function getPlaylistImagePath(playlist: Playlist): string | undefined {
|
|
167
|
+
if (!playlist.imageData || !playlist.imageType) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// determine file extension from mime type
|
|
172
|
+
let extension = ".jpg"; // default
|
|
173
|
+
if (playlist.imageType === "image/png") {
|
|
174
|
+
extension = ".png";
|
|
175
|
+
} else if (playlist.imageType === "image/webp") {
|
|
176
|
+
extension = ".webp";
|
|
177
|
+
} else if (playlist.imageType === "image/gif") {
|
|
178
|
+
extension = ".gif";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return `/data/playlist-cover${extension}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Update PWA manifest with new playlist title
|
|
186
|
+
*/
|
|
187
|
+
export function updatePWAManifest(
|
|
188
|
+
playlistTitle: string,
|
|
189
|
+
playlist?: Playlist
|
|
190
|
+
): void {
|
|
191
|
+
console.log("[trace] updatePWAManifest", playlistTitle);
|
|
192
|
+
const imagePath = playlist ? getPlaylistImagePath(playlist) : undefined;
|
|
193
|
+
generatePWAManifest(playlistTitle, imagePath);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Register service worker
|
|
198
|
+
*/
|
|
199
|
+
async function registerServiceWorker(): Promise<boolean> {
|
|
200
|
+
try {
|
|
201
|
+
if (!("serviceWorker" in navigator)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const swPath = "/sw.js";
|
|
206
|
+
const registration = await navigator.serviceWorker.register(swPath);
|
|
207
|
+
await navigator.serviceWorker.ready;
|
|
208
|
+
|
|
209
|
+
setServiceWorkerReady(true);
|
|
210
|
+
|
|
211
|
+
// Listen for service worker messages
|
|
212
|
+
navigator.serviceWorker.addEventListener("message", (event) => {
|
|
213
|
+
const { type } = event.data;
|
|
214
|
+
if (type === "SW_READY") {
|
|
215
|
+
cacheCurrentPage();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Check if SW is already controlling and cache page if so
|
|
220
|
+
if (navigator.serviceWorker.controller) {
|
|
221
|
+
cacheCurrentPage();
|
|
222
|
+
} else {
|
|
223
|
+
const newWorker =
|
|
224
|
+
registration.active || registration.installing || registration.waiting;
|
|
225
|
+
if (newWorker) {
|
|
226
|
+
newWorker.postMessage({ type: "CLAIM_CLIENTS" });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return true;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.warn("service worker registration failed:", error);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Cache the current page for offline access
|
|
239
|
+
*/
|
|
240
|
+
async function cacheCurrentPage(): Promise<void> {
|
|
241
|
+
try {
|
|
242
|
+
const cache = await caches.open(CACHE_NAME);
|
|
243
|
+
const currentUrl = window.location.href;
|
|
244
|
+
|
|
245
|
+
const cached = await cache.match(currentUrl);
|
|
246
|
+
if (!cached) {
|
|
247
|
+
await cache.add(currentUrl);
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
console.warn("⚠️ Failed to auto-cache page:", error);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Cache an audio file for offline access
|
|
256
|
+
*/
|
|
257
|
+
export async function cacheAudioFile(
|
|
258
|
+
url: string,
|
|
259
|
+
title: string
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
try {
|
|
262
|
+
if (!("caches" in window)) {
|
|
263
|
+
throw new Error("Cache API not supported");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (window.location.protocol === "file:") {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
|
|
271
|
+
navigator.serviceWorker.controller.postMessage({
|
|
272
|
+
type: "CACHE_URL",
|
|
273
|
+
data: { url },
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const cache = await caches.open(CACHE_NAME);
|
|
279
|
+
await cache.add(url);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error(`Failed to cache audio file ${title}:`, error);
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Initialize offline support
|
|
288
|
+
*/
|
|
289
|
+
export async function initializeOfflineSupport(
|
|
290
|
+
playlistTitle?: string,
|
|
291
|
+
playlist?: Playlist
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
const updateOnlineStatus = () => {
|
|
294
|
+
setIsOnline(navigator.onLine);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
window.addEventListener("online", updateOnlineStatus);
|
|
298
|
+
window.addEventListener("offline", updateOnlineStatus);
|
|
299
|
+
|
|
300
|
+
const imagePath = playlist ? getPlaylistImagePath(playlist) : undefined;
|
|
301
|
+
generatePWAManifest(playlistTitle, imagePath);
|
|
302
|
+
await requestPersistentStorage();
|
|
303
|
+
await registerServiceWorker();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Get storage usage information
|
|
308
|
+
*/
|
|
309
|
+
export async function getStorageInfo(): Promise<{
|
|
310
|
+
quota?: number;
|
|
311
|
+
usage?: number;
|
|
312
|
+
usagePercentage?: number;
|
|
313
|
+
persistent?: boolean;
|
|
314
|
+
error?: string;
|
|
315
|
+
quotaFormatted?: string;
|
|
316
|
+
usageFormatted?: string;
|
|
317
|
+
usagePercent?: number;
|
|
318
|
+
}> {
|
|
319
|
+
try {
|
|
320
|
+
const info: {
|
|
321
|
+
quota?: number;
|
|
322
|
+
usage?: number;
|
|
323
|
+
usagePercentage?: number;
|
|
324
|
+
persistent?: boolean;
|
|
325
|
+
error?: string;
|
|
326
|
+
quotaFormatted?: string;
|
|
327
|
+
usageFormatted?: string;
|
|
328
|
+
usagePercent?: number;
|
|
329
|
+
} = {};
|
|
330
|
+
|
|
331
|
+
if ("storage" in navigator && navigator.storage) {
|
|
332
|
+
if ("estimate" in navigator.storage) {
|
|
333
|
+
const estimate = await navigator.storage.estimate();
|
|
334
|
+
info.quota = estimate.quota;
|
|
335
|
+
info.usage = estimate.usage;
|
|
336
|
+
|
|
337
|
+
if (estimate.quota) {
|
|
338
|
+
info.quotaFormatted =
|
|
339
|
+
Math.round(estimate.quota / 1024 / 1024) + " MB";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (estimate.usage) {
|
|
343
|
+
info.usageFormatted =
|
|
344
|
+
Math.round(estimate.usage / 1024 / 1024) + " MB";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (estimate.quota && estimate.usage) {
|
|
348
|
+
info.usagePercent = Math.round(
|
|
349
|
+
(estimate.usage / estimate.quota) * 100
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if ("persisted" in navigator.storage) {
|
|
355
|
+
info.persistent = await navigator.storage.persisted();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return info;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.error("Error getting storage info:", error);
|
|
362
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Check if a URL is cached
|
|
368
|
+
*/
|
|
369
|
+
export async function isUrlCached(url: string): Promise<boolean> {
|
|
370
|
+
try {
|
|
371
|
+
if (!("caches" in window)) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const cache = await caches.open(CACHE_NAME);
|
|
376
|
+
const response = await cache.match(url);
|
|
377
|
+
return !!response;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
console.error("Error checking cache:", error);
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import type { P2PIdentity } from "@freqhole/api-client/storage";
|
|
3
|
+
|
|
4
|
+
// --- mocks (hoisted before module imports) ---
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
mockResolveIdentity,
|
|
8
|
+
mockPersistIdentity,
|
|
9
|
+
mockAcquireLeadership,
|
|
10
|
+
mockCreateWithAlpns,
|
|
11
|
+
} = vi.hoisted(() => ({
|
|
12
|
+
mockResolveIdentity: vi.fn(),
|
|
13
|
+
mockPersistIdentity: vi.fn(),
|
|
14
|
+
mockAcquireLeadership: vi.fn(),
|
|
15
|
+
mockCreateWithAlpns: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@freqhole/api-client/storage", () => ({
|
|
19
|
+
resolveIdentity: mockResolveIdentity,
|
|
20
|
+
persistIdentity: mockPersistIdentity,
|
|
21
|
+
acquireNodeLeadership: mockAcquireLeadership,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@freqhole/midden", () => ({
|
|
25
|
+
MiddenNode: {
|
|
26
|
+
create_with_alpns: mockCreateWithAlpns,
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// automerge types are type-only imports in the service; no runtime mock needed.
|
|
31
|
+
// playlistz constants are plain strings - no mock needed either.
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
startP2P,
|
|
35
|
+
stopP2P,
|
|
36
|
+
getNode,
|
|
37
|
+
getIdentity,
|
|
38
|
+
isLeader,
|
|
39
|
+
onLeadershipChange,
|
|
40
|
+
onIdentityChange,
|
|
41
|
+
getAdapterOptions,
|
|
42
|
+
_resetForTests,
|
|
43
|
+
} from "./p2pService.js";
|
|
44
|
+
|
|
45
|
+
// --- test helpers ---
|
|
46
|
+
|
|
47
|
+
const fakeIdentity = (overrides: Partial<P2PIdentity> = {}): P2PIdentity => ({
|
|
48
|
+
id: "p2p_identity",
|
|
49
|
+
secret_key: new Uint8Array(32).fill(7),
|
|
50
|
+
node_id: "fake-node-id",
|
|
51
|
+
created_at: 1000,
|
|
52
|
+
...overrides,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const fakeMockNode = {
|
|
56
|
+
node_id: vi.fn().mockReturnValue("real-node-id"),
|
|
57
|
+
open_bi: vi.fn(),
|
|
58
|
+
accept: vi.fn().mockResolvedValue(null),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** simulate the leadership callback being called (i.e. this tab won the lock). */
|
|
62
|
+
async function triggerLeader(): Promise<void> {
|
|
63
|
+
const call = mockAcquireLeadership.mock.calls[0];
|
|
64
|
+
if (!call) throw new Error("acquireNodeLeadership was not called");
|
|
65
|
+
await call[0].onAcquired();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- setup / teardown ---
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
_resetForTests();
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
|
|
74
|
+
// default: acquireLeadership never calls onAcquired (another tab holds the lock)
|
|
75
|
+
mockAcquireLeadership.mockReturnValue(() => {});
|
|
76
|
+
|
|
77
|
+
// default: persistIdentity succeeds silently
|
|
78
|
+
mockPersistIdentity.mockResolvedValue(undefined);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
stopP2P();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// --- identity fallback chain ---
|
|
86
|
+
|
|
87
|
+
describe("identity fallback chain", () => {
|
|
88
|
+
it("generates a new identity when none exists anywhere", async () => {
|
|
89
|
+
mockResolveIdentity.mockResolvedValue(null);
|
|
90
|
+
|
|
91
|
+
await startP2P();
|
|
92
|
+
|
|
93
|
+
const identity = getIdentity();
|
|
94
|
+
expect(identity).not.toBeNull();
|
|
95
|
+
expect(identity!.id).toBe("p2p_identity");
|
|
96
|
+
expect(identity!.secret_key).toBeInstanceOf(Uint8Array);
|
|
97
|
+
expect(identity!.secret_key.length).toBe(32);
|
|
98
|
+
// node_id is empty until midden boots
|
|
99
|
+
expect(identity!.node_id).toBe("");
|
|
100
|
+
expect(identity!.created_at).toBeGreaterThan(0);
|
|
101
|
+
// new identity should be persisted
|
|
102
|
+
expect(mockPersistIdentity).toHaveBeenCalledOnce();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("uses existing identity from resolveIdentity when one exists", async () => {
|
|
106
|
+
const existing = fakeIdentity({ node_id: "stored-node-id" });
|
|
107
|
+
mockResolveIdentity.mockResolvedValue(existing);
|
|
108
|
+
|
|
109
|
+
await startP2P();
|
|
110
|
+
|
|
111
|
+
const identity = getIdentity();
|
|
112
|
+
expect(identity).toEqual(existing);
|
|
113
|
+
// should not generate or persist a new identity
|
|
114
|
+
expect(mockPersistIdentity).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("notifies identity listeners after resolution", async () => {
|
|
118
|
+
const existing = fakeIdentity();
|
|
119
|
+
mockResolveIdentity.mockResolvedValue(existing);
|
|
120
|
+
|
|
121
|
+
const listener = vi.fn();
|
|
122
|
+
onIdentityChange(listener);
|
|
123
|
+
|
|
124
|
+
await startP2P();
|
|
125
|
+
|
|
126
|
+
expect(listener).toHaveBeenCalledWith(existing);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("is idempotent: calling startP2P twice has no effect the second time", async () => {
|
|
130
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
131
|
+
|
|
132
|
+
await startP2P();
|
|
133
|
+
await startP2P();
|
|
134
|
+
|
|
135
|
+
// resolveIdentity called exactly once
|
|
136
|
+
expect(mockResolveIdentity).toHaveBeenCalledOnce();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles identity resolution failure gracefully", async () => {
|
|
140
|
+
mockResolveIdentity.mockRejectedValue(new Error("idb exploded"));
|
|
141
|
+
|
|
142
|
+
// should not throw
|
|
143
|
+
await expect(startP2P()).resolves.toBeUndefined();
|
|
144
|
+
expect(getIdentity()).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// --- leadership gating ---
|
|
149
|
+
|
|
150
|
+
describe("leadership gating", () => {
|
|
151
|
+
it("does not boot midden when this tab is not the leader", async () => {
|
|
152
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
153
|
+
// acquireLeadership never calls onAcquired (someone else holds the lock)
|
|
154
|
+
mockAcquireLeadership.mockReturnValue(() => {});
|
|
155
|
+
|
|
156
|
+
await startP2P();
|
|
157
|
+
|
|
158
|
+
expect(getNode()).toBeNull();
|
|
159
|
+
expect(isLeader()).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("boots midden and exposes the node when this tab wins the lock", async () => {
|
|
163
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity({ node_id: "" }));
|
|
164
|
+
mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
|
|
165
|
+
|
|
166
|
+
await startP2P();
|
|
167
|
+
await triggerLeader();
|
|
168
|
+
|
|
169
|
+
expect(isLeader()).toBe(true);
|
|
170
|
+
expect(getNode()).toBe(fakeMockNode);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("updates node_id in the stored identity after midden boots", async () => {
|
|
174
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity({ node_id: "old-id" }));
|
|
175
|
+
mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
|
|
176
|
+
|
|
177
|
+
await startP2P();
|
|
178
|
+
await triggerLeader();
|
|
179
|
+
|
|
180
|
+
// real node_id from midden should be persisted
|
|
181
|
+
const updatedIdentity = getIdentity();
|
|
182
|
+
expect(updatedIdentity!.node_id).toBe("real-node-id");
|
|
183
|
+
expect(mockPersistIdentity).toHaveBeenCalledWith(
|
|
184
|
+
expect.objectContaining({ node_id: "real-node-id" }),
|
|
185
|
+
expect.anything()
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("notifies leadership listeners when acquiring leadership", async () => {
|
|
190
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
191
|
+
mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
|
|
192
|
+
|
|
193
|
+
const listener = vi.fn();
|
|
194
|
+
onLeadershipChange(listener);
|
|
195
|
+
// called immediately with current state (false)
|
|
196
|
+
expect(listener).toHaveBeenCalledWith(false);
|
|
197
|
+
listener.mockClear();
|
|
198
|
+
|
|
199
|
+
await startP2P();
|
|
200
|
+
await triggerLeader();
|
|
201
|
+
|
|
202
|
+
expect(listener).toHaveBeenCalledWith(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("releases leadership and clears node on stopP2P", async () => {
|
|
206
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
207
|
+
mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
|
|
208
|
+
|
|
209
|
+
await startP2P();
|
|
210
|
+
await triggerLeader();
|
|
211
|
+
expect(isLeader()).toBe(true);
|
|
212
|
+
|
|
213
|
+
stopP2P();
|
|
214
|
+
|
|
215
|
+
expect(isLeader()).toBe(false);
|
|
216
|
+
expect(getNode()).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- graceful degradation when wasm is unavailable ---
|
|
221
|
+
|
|
222
|
+
describe("graceful degradation when wasm import fails", () => {
|
|
223
|
+
it("does not throw when midden import fails", async () => {
|
|
224
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
225
|
+
mockCreateWithAlpns.mockRejectedValue(new Error("wasm not available"));
|
|
226
|
+
|
|
227
|
+
await startP2P();
|
|
228
|
+
await triggerLeader();
|
|
229
|
+
|
|
230
|
+
// identity is still set; node is null (midden boot failed)
|
|
231
|
+
expect(getIdentity()).not.toBeNull();
|
|
232
|
+
expect(getNode()).toBeNull();
|
|
233
|
+
// still became leader (lock acquired) even though midden failed
|
|
234
|
+
expect(isLeader()).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does not throw when midden module is missing entirely", async () => {
|
|
238
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
239
|
+
// simulate dynamic import("@freqhole/midden") rejecting with module-not-found
|
|
240
|
+
mockCreateWithAlpns.mockImplementation(() => {
|
|
241
|
+
throw new TypeError("cannot find module 'midden'");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await startP2P();
|
|
245
|
+
await triggerLeader();
|
|
246
|
+
|
|
247
|
+
expect(getNode()).toBeNull();
|
|
248
|
+
expect(getIdentity()).not.toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// --- adapter options ---
|
|
253
|
+
|
|
254
|
+
describe("getAdapterOptions", () => {
|
|
255
|
+
it("getNode rejects when node is not available", async () => {
|
|
256
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
257
|
+
|
|
258
|
+
await startP2P();
|
|
259
|
+
const opts = getAdapterOptions();
|
|
260
|
+
|
|
261
|
+
await expect(opts.getNode()).rejects.toThrow(
|
|
262
|
+
"p2p: midden node is not available"
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("getIdentity returns the current identity", async () => {
|
|
267
|
+
const id = fakeIdentity();
|
|
268
|
+
mockResolveIdentity.mockResolvedValue(id);
|
|
269
|
+
|
|
270
|
+
await startP2P();
|
|
271
|
+
const opts = getAdapterOptions();
|
|
272
|
+
|
|
273
|
+
await expect(opts.getIdentity()).resolves.toEqual(id);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("getNode resolves the midden node after boot", async () => {
|
|
277
|
+
mockResolveIdentity.mockResolvedValue(fakeIdentity());
|
|
278
|
+
mockCreateWithAlpns.mockResolvedValue(fakeMockNode);
|
|
279
|
+
|
|
280
|
+
await startP2P();
|
|
281
|
+
await triggerLeader();
|
|
282
|
+
|
|
283
|
+
const opts = getAdapterOptions();
|
|
284
|
+
await expect(opts.getNode()).resolves.toBe(fakeMockNode);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("onIdentityChange subscribes to identity updates", async () => {
|
|
288
|
+
const id = fakeIdentity();
|
|
289
|
+
mockResolveIdentity.mockResolvedValue(id);
|
|
290
|
+
|
|
291
|
+
const opts = getAdapterOptions();
|
|
292
|
+
const listener = vi.fn();
|
|
293
|
+
const unsub = opts.onIdentityChange!(listener);
|
|
294
|
+
|
|
295
|
+
await startP2P();
|
|
296
|
+
|
|
297
|
+
expect(listener).toHaveBeenCalledWith(id);
|
|
298
|
+
unsub();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("syncAlpn is the automerge ALPN", () => {
|
|
302
|
+
const opts = getAdapterOptions();
|
|
303
|
+
expect(opts.syncAlpn).toBe("iroh/automerge-repo/1");
|
|
304
|
+
});
|
|
305
|
+
});
|