@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,74 @@
|
|
|
1
|
+
|
|
2
|
+
import { createContext, useContext, ParentComponent } from "solid-js";
|
|
3
|
+
import { usePlaylistManager } from "../hooks/usePlaylistManager.js";
|
|
4
|
+
|
|
5
|
+
import { useSongState } from "../hooks/useSongState.js";
|
|
6
|
+
import { useUIState } from "../hooks/useUIState.js";
|
|
7
|
+
import { useDragAndDrop } from "../hooks/useDragAndDrop.js";
|
|
8
|
+
import { useImageModal } from "../hooks/useImageModal.js";
|
|
9
|
+
|
|
10
|
+
interface PlaylistzContextType {
|
|
11
|
+
playlistManager: ReturnType<typeof usePlaylistManager>;
|
|
12
|
+
songState: ReturnType<typeof useSongState>;
|
|
13
|
+
uiState: ReturnType<typeof useUIState>;
|
|
14
|
+
dragAndDrop: ReturnType<typeof useDragAndDrop>;
|
|
15
|
+
imageModal: ReturnType<typeof useImageModal>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PlaylistzContext = createContext<PlaylistzContextType>();
|
|
19
|
+
|
|
20
|
+
// provider component
|
|
21
|
+
export const PlaylistzProvider: ParentComponent = (props) => {
|
|
22
|
+
// init all hookz once at the top level
|
|
23
|
+
const playlistManager = usePlaylistManager();
|
|
24
|
+
const songState = useSongState();
|
|
25
|
+
const uiState = useUIState();
|
|
26
|
+
const dragAndDrop = useDragAndDrop();
|
|
27
|
+
const imageModal = useImageModal();
|
|
28
|
+
|
|
29
|
+
const contextValue: PlaylistzContextType = {
|
|
30
|
+
playlistManager,
|
|
31
|
+
songState,
|
|
32
|
+
uiState,
|
|
33
|
+
dragAndDrop,
|
|
34
|
+
imageModal,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<PlaylistzContext.Provider value={contextValue}>
|
|
39
|
+
{props.children}
|
|
40
|
+
</PlaylistzContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// hook to use the context
|
|
45
|
+
export function usePlaylistzContext() {
|
|
46
|
+
const context = useContext(PlaylistzContext);
|
|
47
|
+
if (!context) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"usePlaylistzContext must be used within a PlaylistzProvider"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return context;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// individual hookz for convenience
|
|
56
|
+
export function usePlaylistzManager() {
|
|
57
|
+
return usePlaylistzContext().playlistManager;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function usePlaylistzSongs() {
|
|
61
|
+
return usePlaylistzContext().songState;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function usePlaylistzUI() {
|
|
65
|
+
return usePlaylistzContext().uiState;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function usePlaylistzDragDrop() {
|
|
69
|
+
return usePlaylistzContext().dragAndDrop;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function usePlaylistzImageModal() {
|
|
73
|
+
return usePlaylistzContext().imageModal;
|
|
74
|
+
}
|
package/src/dev-hooks.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// dev-only module: registers window.__* test hooks.
|
|
2
|
+
//
|
|
3
|
+
// this file is dynamically imported only when import.meta.env.DEV is true
|
|
4
|
+
// (see src/components/index.tsx). never present in production builds.
|
|
5
|
+
//
|
|
6
|
+
// the actual mock implementations live next to their service:
|
|
7
|
+
// src/services/audioService.dev.ts - __seekTo, __triggerTrackEnd, __triggerAudioError
|
|
8
|
+
// src/services/blobTransferService.dev.ts - __mockBlobFetch, __clearMockBlobFetch,
|
|
9
|
+
// __evictBlob, __setBlobFetchTimeout, __fetchBlobBySha
|
|
10
|
+
//
|
|
11
|
+
// this file only imports and calls the register functions.
|
|
12
|
+
|
|
13
|
+
import { registerAudioDevHooks } from "./services/audioService.dev.js";
|
|
14
|
+
import { registerBlobDevHooks } from "./services/blobTransferService.dev.js";
|
|
15
|
+
import { getAllDocIndexEntries, addDocIndexEntry } from "./services/docIndexService.js";
|
|
16
|
+
import type { DocIndexEntry } from "./services/indexedDBService.js";
|
|
17
|
+
|
|
18
|
+
registerAudioDevHooks();
|
|
19
|
+
registerBlobDevHooks();
|
|
20
|
+
|
|
21
|
+
// docIndex test hooks: allow e2e tests to read/patch docIndex entries without
|
|
22
|
+
// raw idb access, so they use the same service layer as the app.
|
|
23
|
+
(window as Window & {
|
|
24
|
+
__getDocIndexEntries?: () => Promise<DocIndexEntry[]>;
|
|
25
|
+
__patchDocIndexEntry?: (docId: string, patch: Partial<DocIndexEntry>) => Promise<void>;
|
|
26
|
+
}).__getDocIndexEntries = getAllDocIndexEntries;
|
|
27
|
+
|
|
28
|
+
(window as Window & {
|
|
29
|
+
__patchDocIndexEntry?: (docId: string, patch: Partial<DocIndexEntry>) => Promise<void>;
|
|
30
|
+
}).__patchDocIndexEntry = async (docId: string, patch: unknown) => {
|
|
31
|
+
const entries = await getAllDocIndexEntries();
|
|
32
|
+
const existing = entries.find((e) => e.docId === docId);
|
|
33
|
+
if (!existing) throw new Error(`docIndex entry not found: ${docId}`);
|
|
34
|
+
await addDocIndexEntry({ ...existing, ...(patch as Partial<DocIndexEntry>) });
|
|
35
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// solid hook: reactive list of docIndex entries for the sidebar.
|
|
2
|
+
//
|
|
3
|
+
// follows the same live-query pattern as usePlaylistsQuery:
|
|
4
|
+
// - initial fetch on mount
|
|
5
|
+
// - BroadcastChannel listener for cross-tab invalidation
|
|
6
|
+
// - onCleanup closes the channel when the owner is disposed
|
|
7
|
+
|
|
8
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
9
|
+
import type { Accessor } from "solid-js";
|
|
10
|
+
import { getAllDocIndexEntries, DOC_INDEX_CHANGE_EVENT } from "../services/docIndexService.js";
|
|
11
|
+
import { DB_NAME, DOC_INDEX_STORE } from "../services/indexedDBService.js";
|
|
12
|
+
import type { DocIndexEntry } from "../services/indexedDBService.js";
|
|
13
|
+
import { log } from "../utils/log.js";
|
|
14
|
+
|
|
15
|
+
// returns a solid accessor that stays up-to-date with the docIndex store.
|
|
16
|
+
// call inside a component or createRoot.
|
|
17
|
+
export function createDocIndexQuery(): Accessor<DocIndexEntry[]> {
|
|
18
|
+
const [entries, setEntries] = createSignal<DocIndexEntry[]>([], {
|
|
19
|
+
equals: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let _refreshCalls = 0;
|
|
23
|
+
async function refresh(): Promise<void> {
|
|
24
|
+
_refreshCalls++;
|
|
25
|
+
log.debug("docindex", "refresh #", String(_refreshCalls));
|
|
26
|
+
const all = await getAllDocIndexEntries();
|
|
27
|
+
log.debug("docindex", "refresh #", String(_refreshCalls), "got", String(all.length), "entries");
|
|
28
|
+
setEntries(all);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
void refresh();
|
|
32
|
+
|
|
33
|
+
// BroadcastChannel: cross-tab invalidation
|
|
34
|
+
const bc = new BroadcastChannel(`${DB_NAME}-changes`);
|
|
35
|
+
bc.onmessage = (e: MessageEvent) => {
|
|
36
|
+
if (e.data?.type === "mutation" && e.data.store === DOC_INDEX_STORE) {
|
|
37
|
+
log.debug("docindex", "broadcast invalidation received");
|
|
38
|
+
void refresh();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// CustomEvent: same-page invalidation - works on file:// (null origin)
|
|
43
|
+
// where BroadcastChannel may not deliver same-page messages reliably.
|
|
44
|
+
const onDocIndexChanged = () => { void refresh(); };
|
|
45
|
+
window.addEventListener(DOC_INDEX_CHANGE_EVENT, onDocIndexChanged);
|
|
46
|
+
|
|
47
|
+
onCleanup(() => {
|
|
48
|
+
bc.close();
|
|
49
|
+
window.removeEventListener(DOC_INDEX_CHANGE_EVENT, onDocIndexChanged);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return entries;
|
|
53
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// tests for createDocStore solid adapter.
|
|
2
|
+
//
|
|
3
|
+
// uses a lightweight mock DocHandle (no automerge wasm needed) to test
|
|
4
|
+
// the solid reactivity layer and zod facade independently.
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi } from "vitest";
|
|
7
|
+
import { createRoot } from "solid-js";
|
|
8
|
+
import type {
|
|
9
|
+
DocHandleChangePayload,
|
|
10
|
+
DocHandleDeletePayload,
|
|
11
|
+
} from "@automerge/automerge-repo";
|
|
12
|
+
import { createDocStore, changeDoc } from "./createDocStore.js";
|
|
13
|
+
import type { DocHandle } from "@automerge/automerge-repo";
|
|
14
|
+
import type { PlaylistDoc } from "@freqhole/api-client/playlistz";
|
|
15
|
+
|
|
16
|
+
// --- minimal mock DocHandle ---
|
|
17
|
+
|
|
18
|
+
type EventName = "change" | "delete";
|
|
19
|
+
type HandlerFn = (payload: unknown) => void;
|
|
20
|
+
|
|
21
|
+
function createMockHandle(initialDoc: unknown = undefined): {
|
|
22
|
+
handle: DocHandle<unknown>;
|
|
23
|
+
setDoc: (doc: unknown) => void;
|
|
24
|
+
emitChange: (doc: unknown) => void;
|
|
25
|
+
emitDelete: () => void;
|
|
26
|
+
resolveReady: () => void;
|
|
27
|
+
rejectReady: (err: unknown) => void;
|
|
28
|
+
offSpy: ReturnType<typeof vi.fn>;
|
|
29
|
+
} {
|
|
30
|
+
let currentDoc = initialDoc;
|
|
31
|
+
const handlers = new Map<EventName, Set<HandlerFn>>();
|
|
32
|
+
let readyResolve: () => void;
|
|
33
|
+
let readyReject: (err: unknown) => void;
|
|
34
|
+
|
|
35
|
+
const readyPromise = new Promise<void>((res, rej) => {
|
|
36
|
+
readyResolve = res;
|
|
37
|
+
readyReject = rej;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const offSpy = vi.fn();
|
|
41
|
+
|
|
42
|
+
const handle = {
|
|
43
|
+
doc: () => currentDoc,
|
|
44
|
+
whenReady: () => readyPromise,
|
|
45
|
+
on: (event: EventName, handler: HandlerFn) => {
|
|
46
|
+
if (!handlers.has(event)) handlers.set(event, new Set());
|
|
47
|
+
handlers.get(event)!.add(handler);
|
|
48
|
+
},
|
|
49
|
+
off: offSpy,
|
|
50
|
+
change: vi.fn((fn: (d: unknown) => void) => {
|
|
51
|
+
fn(currentDoc);
|
|
52
|
+
const set = handlers.get("change");
|
|
53
|
+
if (set) {
|
|
54
|
+
const payload: DocHandleChangePayload<unknown> = {
|
|
55
|
+
handle: handle as unknown as DocHandle<unknown>,
|
|
56
|
+
doc: currentDoc as ReturnType<typeof handle.doc>,
|
|
57
|
+
patches: [],
|
|
58
|
+
patchInfo: {
|
|
59
|
+
before: currentDoc as ReturnType<typeof handle.doc>,
|
|
60
|
+
after: currentDoc as ReturnType<typeof handle.doc>,
|
|
61
|
+
source: "change",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
set.forEach((h) => h(payload));
|
|
65
|
+
}
|
|
66
|
+
}),
|
|
67
|
+
} as unknown as DocHandle<unknown>;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
handle,
|
|
71
|
+
setDoc: (d: unknown) => {
|
|
72
|
+
currentDoc = d;
|
|
73
|
+
},
|
|
74
|
+
emitChange: (doc: unknown) => {
|
|
75
|
+
currentDoc = doc;
|
|
76
|
+
const set = handlers.get("change");
|
|
77
|
+
if (set) {
|
|
78
|
+
const payload: DocHandleChangePayload<unknown> = {
|
|
79
|
+
handle: handle as unknown as DocHandle<unknown>,
|
|
80
|
+
doc: doc as ReturnType<typeof handle.doc>,
|
|
81
|
+
patches: [],
|
|
82
|
+
patchInfo: {
|
|
83
|
+
before: doc as ReturnType<typeof handle.doc>,
|
|
84
|
+
after: doc as ReturnType<typeof handle.doc>,
|
|
85
|
+
source: "change",
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
set.forEach((h) => h(payload));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
emitDelete: () => {
|
|
92
|
+
const set = handlers.get("delete");
|
|
93
|
+
if (set) {
|
|
94
|
+
const payload: DocHandleDeletePayload<unknown> = {
|
|
95
|
+
handle: handle as unknown as DocHandle<unknown>,
|
|
96
|
+
};
|
|
97
|
+
set.forEach((h) => h(payload));
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
resolveReady: () => readyResolve(),
|
|
101
|
+
rejectReady: (err: unknown) => readyReject(err),
|
|
102
|
+
offSpy,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("createDocStore", () => {
|
|
107
|
+
it("returns loading=true and default doc when handle doc is undefined", () => {
|
|
108
|
+
createRoot((dispose) => {
|
|
109
|
+
const { handle } = createMockHandle(undefined);
|
|
110
|
+
const { doc, loading } = createDocStore(handle);
|
|
111
|
+
|
|
112
|
+
expect(loading()).toBe(true);
|
|
113
|
+
// zod defaults produce version 1 empty doc
|
|
114
|
+
expect(doc().version).toBe(1);
|
|
115
|
+
expect(doc().title).toBe("");
|
|
116
|
+
|
|
117
|
+
dispose();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns loading=false immediately when handle already has a doc", () => {
|
|
122
|
+
createRoot((dispose) => {
|
|
123
|
+
const initialDoc = {
|
|
124
|
+
version: 1,
|
|
125
|
+
title: "loaded",
|
|
126
|
+
description: "",
|
|
127
|
+
createdAt: new Date().toISOString(),
|
|
128
|
+
lastModified: new Date().toISOString(),
|
|
129
|
+
lastModifiedBy: "",
|
|
130
|
+
images: [],
|
|
131
|
+
urls: [],
|
|
132
|
+
songs: {},
|
|
133
|
+
order: [],
|
|
134
|
+
peers: {},
|
|
135
|
+
};
|
|
136
|
+
const { handle } = createMockHandle(initialDoc);
|
|
137
|
+
const { doc, loading } = createDocStore(handle);
|
|
138
|
+
|
|
139
|
+
expect(loading()).toBe(false);
|
|
140
|
+
expect(doc().title).toBe("loaded");
|
|
141
|
+
|
|
142
|
+
dispose();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("updates doc signal on handle change event", () => {
|
|
147
|
+
createRoot((dispose) => {
|
|
148
|
+
const { handle, emitChange } = createMockHandle({
|
|
149
|
+
version: 1,
|
|
150
|
+
title: "original",
|
|
151
|
+
description: "",
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
lastModified: new Date().toISOString(),
|
|
154
|
+
lastModifiedBy: "",
|
|
155
|
+
images: [],
|
|
156
|
+
urls: [],
|
|
157
|
+
songs: {},
|
|
158
|
+
order: [],
|
|
159
|
+
peers: {},
|
|
160
|
+
});
|
|
161
|
+
const { doc } = createDocStore(handle);
|
|
162
|
+
|
|
163
|
+
expect(doc().title).toBe("original");
|
|
164
|
+
|
|
165
|
+
emitChange({
|
|
166
|
+
version: 1,
|
|
167
|
+
title: "updated",
|
|
168
|
+
description: "",
|
|
169
|
+
createdAt: new Date().toISOString(),
|
|
170
|
+
lastModified: new Date().toISOString(),
|
|
171
|
+
lastModifiedBy: "",
|
|
172
|
+
images: [],
|
|
173
|
+
urls: [],
|
|
174
|
+
songs: {},
|
|
175
|
+
order: [],
|
|
176
|
+
peers: {},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(doc().title).toBe("updated");
|
|
180
|
+
|
|
181
|
+
dispose();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("degrades corrupt doc to zod defaults on change", () => {
|
|
186
|
+
createRoot((dispose) => {
|
|
187
|
+
const { handle, emitChange } = createMockHandle({ version: 1 });
|
|
188
|
+
const { doc } = createDocStore(handle);
|
|
189
|
+
|
|
190
|
+
// emit a corrupt doc that fails zod validation
|
|
191
|
+
emitChange({ version: 999, badField: true });
|
|
192
|
+
|
|
193
|
+
// zod degrades to defaults
|
|
194
|
+
expect(doc().version).toBe(1);
|
|
195
|
+
expect(doc().title).toBe("");
|
|
196
|
+
|
|
197
|
+
dispose();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("sets loading=false when delete event fires", () => {
|
|
202
|
+
createRoot((dispose) => {
|
|
203
|
+
const { handle, emitDelete } = createMockHandle(undefined);
|
|
204
|
+
const { loading } = createDocStore(handle);
|
|
205
|
+
|
|
206
|
+
expect(loading()).toBe(true);
|
|
207
|
+
emitDelete();
|
|
208
|
+
expect(loading()).toBe(false);
|
|
209
|
+
|
|
210
|
+
dispose();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("sets loading=false when whenReady resolves", async () => {
|
|
215
|
+
await createRoot(async (dispose) => {
|
|
216
|
+
const { handle, resolveReady, setDoc } = createMockHandle(undefined);
|
|
217
|
+
const { loading } = createDocStore(handle);
|
|
218
|
+
|
|
219
|
+
expect(loading()).toBe(true);
|
|
220
|
+
|
|
221
|
+
setDoc({
|
|
222
|
+
version: 1,
|
|
223
|
+
title: "",
|
|
224
|
+
description: "",
|
|
225
|
+
createdAt: new Date().toISOString(),
|
|
226
|
+
lastModified: new Date().toISOString(),
|
|
227
|
+
lastModifiedBy: "",
|
|
228
|
+
images: [],
|
|
229
|
+
urls: [],
|
|
230
|
+
songs: {},
|
|
231
|
+
order: [],
|
|
232
|
+
peers: {},
|
|
233
|
+
});
|
|
234
|
+
resolveReady();
|
|
235
|
+
|
|
236
|
+
// flush microtasks
|
|
237
|
+
await Promise.resolve();
|
|
238
|
+
await Promise.resolve();
|
|
239
|
+
|
|
240
|
+
expect(loading()).toBe(false);
|
|
241
|
+
|
|
242
|
+
dispose();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("sets loading=false when whenReady rejects", async () => {
|
|
247
|
+
await createRoot(async (dispose) => {
|
|
248
|
+
const { handle, rejectReady } = createMockHandle(undefined);
|
|
249
|
+
const { loading } = createDocStore(handle);
|
|
250
|
+
|
|
251
|
+
rejectReady(new Error("unavailable"));
|
|
252
|
+
|
|
253
|
+
await Promise.resolve();
|
|
254
|
+
await Promise.resolve();
|
|
255
|
+
|
|
256
|
+
expect(loading()).toBe(false);
|
|
257
|
+
|
|
258
|
+
dispose();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("unsubscribes handlers on cleanup (off called with correct args)", () => {
|
|
263
|
+
const { handle, offSpy } = createMockHandle({ version: 1 });
|
|
264
|
+
|
|
265
|
+
const dispose = createRoot((disposeRoot) => {
|
|
266
|
+
createDocStore(handle);
|
|
267
|
+
return disposeRoot;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
dispose();
|
|
271
|
+
|
|
272
|
+
// off should have been called for both "change" and "delete"
|
|
273
|
+
expect(offSpy).toHaveBeenCalledWith("change", expect.any(Function));
|
|
274
|
+
expect(offSpy).toHaveBeenCalledWith("delete", expect.any(Function));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("degrades null/undefined doc to zod defaults", () => {
|
|
278
|
+
createRoot((dispose) => {
|
|
279
|
+
const { handle, emitChange } = createMockHandle({ version: 1 });
|
|
280
|
+
const { doc } = createDocStore(handle);
|
|
281
|
+
|
|
282
|
+
emitChange(null);
|
|
283
|
+
expect(doc().version).toBe(1);
|
|
284
|
+
expect(doc().title).toBe("");
|
|
285
|
+
|
|
286
|
+
dispose();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("changeDoc", () => {
|
|
292
|
+
it("calls handle.change with the mutator function", () => {
|
|
293
|
+
const mockChange = vi.fn();
|
|
294
|
+
const handle = {
|
|
295
|
+
change: mockChange,
|
|
296
|
+
} as unknown as DocHandle<PlaylistDoc>;
|
|
297
|
+
|
|
298
|
+
const mutator = vi.fn();
|
|
299
|
+
changeDoc(handle, mutator);
|
|
300
|
+
|
|
301
|
+
expect(mockChange).toHaveBeenCalledWith(mutator);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// solid adapter over an automerge DocHandle for PlaylistDoc.
|
|
2
|
+
//
|
|
3
|
+
// usage inside a solid component or reactive root:
|
|
4
|
+
//
|
|
5
|
+
// const handle = await findPlaylistDoc(url);
|
|
6
|
+
// const { doc, loading } = createDocStore(handle);
|
|
7
|
+
// // doc() is always a zod-parsed PlaylistDoc (defaults on corrupt/missing)
|
|
8
|
+
// // loading() is true until the handle is ready or terminal
|
|
9
|
+
|
|
10
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
11
|
+
import type { Accessor } from "solid-js";
|
|
12
|
+
import type {
|
|
13
|
+
DocHandle,
|
|
14
|
+
DocHandleChangePayload,
|
|
15
|
+
DocHandleDeletePayload,
|
|
16
|
+
} from "@automerge/automerge-repo";
|
|
17
|
+
import {
|
|
18
|
+
parsePlaylistDoc,
|
|
19
|
+
type PlaylistDoc,
|
|
20
|
+
} from "@freqhole/api-client/playlistz";
|
|
21
|
+
|
|
22
|
+
export interface DocStore {
|
|
23
|
+
doc: Accessor<PlaylistDoc>;
|
|
24
|
+
loading: Accessor<boolean>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// create a reactive solid store backed by an automerge DocHandle.
|
|
28
|
+
// the doc accessor is always a zod-validated PlaylistDoc snapshot -
|
|
29
|
+
// corrupt or future-versioned peer data degrades to defaults.
|
|
30
|
+
// loading becomes false once whenReady() resolves or rejects.
|
|
31
|
+
export function createDocStore(handle: DocHandle<unknown>): DocStore {
|
|
32
|
+
// try to read whatever the handle has now (may be undefined before ready)
|
|
33
|
+
let initialRaw: unknown;
|
|
34
|
+
try {
|
|
35
|
+
initialRaw = handle.doc();
|
|
36
|
+
} catch {
|
|
37
|
+
initialRaw = undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const [loading, setLoading] = createSignal(initialRaw === undefined);
|
|
41
|
+
const [doc, setDoc] = createSignal<PlaylistDoc>(
|
|
42
|
+
parsePlaylistDoc(initialRaw),
|
|
43
|
+
{ equals: false }
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// resolve handle readiness in the background
|
|
47
|
+
handle
|
|
48
|
+
.whenReady()
|
|
49
|
+
.then(() => {
|
|
50
|
+
let current: unknown;
|
|
51
|
+
try {
|
|
52
|
+
current = handle.doc();
|
|
53
|
+
} catch {
|
|
54
|
+
current = undefined;
|
|
55
|
+
}
|
|
56
|
+
setLoading(false);
|
|
57
|
+
setDoc(parsePlaylistDoc(current));
|
|
58
|
+
})
|
|
59
|
+
.catch(() => {
|
|
60
|
+
setLoading(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const changeHandler = (payload: DocHandleChangePayload<unknown>) => {
|
|
64
|
+
setDoc(parsePlaylistDoc(payload.doc));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const deleteHandler = (_payload: DocHandleDeletePayload<unknown>) => {
|
|
68
|
+
setLoading(false);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
handle.on("change", changeHandler);
|
|
72
|
+
handle.on("delete", deleteHandler);
|
|
73
|
+
|
|
74
|
+
onCleanup(() => {
|
|
75
|
+
handle.off("change", changeHandler);
|
|
76
|
+
handle.off("delete", deleteHandler);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return { doc, loading };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// convenience wrapper: apply a mutation to a playlist doc handle.
|
|
83
|
+
// the mutatorFn receives a mutable automerge draft and should modify it
|
|
84
|
+
// in place (safe to call the shared mutation helpers from freqhole-api-client).
|
|
85
|
+
export function changeDoc(
|
|
86
|
+
handle: DocHandle<PlaylistDoc>,
|
|
87
|
+
mutatorFn: (draft: PlaylistDoc) => void
|
|
88
|
+
): void {
|
|
89
|
+
handle.change(mutatorFn);
|
|
90
|
+
}
|