@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,120 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createRoot } from "solid-js";
|
|
3
|
+
import { usePlaylistsQuery } from "./usePlaylistsQuery.js";
|
|
4
|
+
import type { Playlist } from "../types/playlist.js";
|
|
5
|
+
|
|
6
|
+
// mock all dependencies of usePlaylistsQuery
|
|
7
|
+
vi.mock("./createDocIndexQuery.js", () => ({
|
|
8
|
+
createDocIndexQuery: vi.fn(() => () => []),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../services/automergeRepo.js", () => ({
|
|
12
|
+
findPlaylistDoc: vi.fn(),
|
|
13
|
+
getRepo: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@freqhole/api-client/playlistz", () => ({
|
|
17
|
+
parsePlaylistDoc: vi.fn((raw: any) => raw ?? {}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("../services/playlistDocService.js", () => ({
|
|
21
|
+
docToPlaylist: vi.fn((docId: string, _doc: any): Playlist => ({
|
|
22
|
+
id: docId,
|
|
23
|
+
title: "mocked playlist",
|
|
24
|
+
description: undefined,
|
|
25
|
+
createdAt: 0,
|
|
26
|
+
updatedAt: 0,
|
|
27
|
+
songIds: [],
|
|
28
|
+
})),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
describe("usePlaylistsQuery", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("basic structure", () => {
|
|
41
|
+
it("returns an object with a playlists signal", () => {
|
|
42
|
+
createRoot((dispose) => {
|
|
43
|
+
const result = usePlaylistsQuery();
|
|
44
|
+
expect(typeof result).toBe("object");
|
|
45
|
+
expect(typeof result.playlists).toBe("function");
|
|
46
|
+
dispose();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns an empty array initially when no docIndex entries exist", () => {
|
|
51
|
+
createRoot((dispose) => {
|
|
52
|
+
const { playlists } = usePlaylistsQuery();
|
|
53
|
+
expect(Array.isArray(playlists())).toBe(true);
|
|
54
|
+
dispose();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("when docIndex has entries", () => {
|
|
60
|
+
it("resolves playlist data from docIndex entries", async () => {
|
|
61
|
+
const { createDocIndexQuery } = await import("./createDocIndexQuery.js");
|
|
62
|
+
const { findPlaylistDoc } = await import("../services/automergeRepo.js");
|
|
63
|
+
|
|
64
|
+
vi.mocked(createDocIndexQuery).mockReturnValue(() => [
|
|
65
|
+
{
|
|
66
|
+
docId: "automerge:abc123",
|
|
67
|
+
title: "test playlist",
|
|
68
|
+
addedAt: 1000,
|
|
69
|
+
peers: [],
|
|
70
|
+
acl: {},
|
|
71
|
+
localDraft: false,
|
|
72
|
+
} as any,
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
vi.mocked(findPlaylistDoc).mockResolvedValue({
|
|
76
|
+
doc: () => ({ title: "test playlist", songs: [] }),
|
|
77
|
+
} as any);
|
|
78
|
+
|
|
79
|
+
let resolvedPlaylists: Playlist[] = [];
|
|
80
|
+
await new Promise<void>((resolve) => {
|
|
81
|
+
createRoot((dispose) => {
|
|
82
|
+
const { playlists } = usePlaylistsQuery();
|
|
83
|
+
// give the effect time to run and resolve
|
|
84
|
+
setTimeout(() => {
|
|
85
|
+
resolvedPlaylists = playlists();
|
|
86
|
+
dispose();
|
|
87
|
+
resolve();
|
|
88
|
+
}, 50);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// playlists may be empty or resolved depending on timing, but no error should throw
|
|
93
|
+
expect(Array.isArray(resolvedPlaylists)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("cleanup", () => {
|
|
98
|
+
it("cleans up without throwing when root is disposed", () => {
|
|
99
|
+
expect(() => {
|
|
100
|
+
createRoot((dispose) => {
|
|
101
|
+
usePlaylistsQuery();
|
|
102
|
+
dispose();
|
|
103
|
+
});
|
|
104
|
+
}).not.toThrow();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("handles multiple instances independently", () => {
|
|
108
|
+
createRoot((dispose1) => {
|
|
109
|
+
const r1 = usePlaylistsQuery();
|
|
110
|
+
createRoot((dispose2) => {
|
|
111
|
+
const r2 = usePlaylistsQuery();
|
|
112
|
+
expect(typeof r1.playlists).toBe("function");
|
|
113
|
+
expect(typeof r2.playlists).toBe("function");
|
|
114
|
+
dispose2();
|
|
115
|
+
});
|
|
116
|
+
dispose1();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createSignal, createEffect } from "solid-js";
|
|
2
|
+
import { createDocIndexQuery } from "./createDocIndexQuery.js";
|
|
3
|
+
import { findPlaylistDoc } from "../services/automergeRepo.js";
|
|
4
|
+
import { parsePlaylistDoc } from "@freqhole/api-client/playlistz";
|
|
5
|
+
import { docToPlaylist } from "../services/playlistDocService.js";
|
|
6
|
+
import type { Playlist } from "../types/playlist.js";
|
|
7
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
|
8
|
+
|
|
9
|
+
// solid hook that creates a reactive playlist list backed by the docIndex.
|
|
10
|
+
// replaces the old idb live-query approach.
|
|
11
|
+
export function usePlaylistsQuery() {
|
|
12
|
+
const [playlists, setPlaylists] = createSignal<Playlist[]>([], {
|
|
13
|
+
equals: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const entries = createDocIndexQuery();
|
|
17
|
+
|
|
18
|
+
createEffect(() => {
|
|
19
|
+
const list = entries();
|
|
20
|
+
Promise.all(
|
|
21
|
+
list.map(async (entry) => {
|
|
22
|
+
try {
|
|
23
|
+
const handle = await findPlaylistDoc(entry.docId as AutomergeUrl);
|
|
24
|
+
const raw = handle.doc();
|
|
25
|
+
const doc = parsePlaylistDoc(raw ?? {});
|
|
26
|
+
return docToPlaylist(entry.docId, doc);
|
|
27
|
+
} catch {
|
|
28
|
+
return {
|
|
29
|
+
id: entry.docId,
|
|
30
|
+
title: entry.title,
|
|
31
|
+
description: undefined,
|
|
32
|
+
createdAt: entry.addedAt,
|
|
33
|
+
updatedAt: entry.addedAt,
|
|
34
|
+
songIds: [],
|
|
35
|
+
} as Playlist;
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
).then((resolved) => setPlaylists(resolved));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
playlists,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createRoot } from "solid-js";
|
|
3
|
+
import { useSongState } from "./useSongState.js";
|
|
4
|
+
import type { Song } from "../types/playlist.js";
|
|
5
|
+
|
|
6
|
+
vi.mock("../services/audioService.js", () => ({
|
|
7
|
+
playSong: vi.fn(),
|
|
8
|
+
playSongFromPlaylist: vi.fn(),
|
|
9
|
+
togglePlayback: vi.fn(),
|
|
10
|
+
audioState: {
|
|
11
|
+
currentSong: vi.fn(() => null),
|
|
12
|
+
isPlaying: vi.fn(() => false),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("../services/playlistDocService.js", () => ({
|
|
17
|
+
updateSongInDoc: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const mockSong: Song = {
|
|
21
|
+
id: "song-1",
|
|
22
|
+
playlistId: "pl-1",
|
|
23
|
+
title: "test song",
|
|
24
|
+
artist: "test artist",
|
|
25
|
+
album: "test album",
|
|
26
|
+
duration: 180,
|
|
27
|
+
originalFilename: "test.mp3",
|
|
28
|
+
fileSize: 5000000,
|
|
29
|
+
mimeType: "audio/mpeg",
|
|
30
|
+
createdAt: Date.now(),
|
|
31
|
+
updatedAt: Date.now(),
|
|
32
|
+
position: 0,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mockSong2: Song = { ...mockSong, id: "song-2", title: "song two" };
|
|
36
|
+
|
|
37
|
+
describe("useSongState edit mode", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("starts with no edit mode active", () => {
|
|
43
|
+
createRoot((dispose) => {
|
|
44
|
+
const hook = useSongState();
|
|
45
|
+
expect(hook.editingSong()).toBeNull();
|
|
46
|
+
expect(hook.editingPlaylist()).toBe(false);
|
|
47
|
+
expect(hook.isEditMode()).toBe(false);
|
|
48
|
+
dispose();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("playlist edit mode", () => {
|
|
53
|
+
it("activates playlist edit mode", () => {
|
|
54
|
+
createRoot((dispose) => {
|
|
55
|
+
const hook = useSongState();
|
|
56
|
+
hook.handleEditPlaylist();
|
|
57
|
+
expect(hook.editingPlaylist()).toBe(true);
|
|
58
|
+
expect(hook.editingSong()).toBeNull();
|
|
59
|
+
expect(hook.isEditMode()).toBe(true);
|
|
60
|
+
dispose();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("clears song edit when entering playlist edit", () => {
|
|
65
|
+
createRoot((dispose) => {
|
|
66
|
+
const hook = useSongState();
|
|
67
|
+
hook.handleEditSong(mockSong);
|
|
68
|
+
hook.handleEditPlaylist();
|
|
69
|
+
expect(hook.editingSong()).toBeNull();
|
|
70
|
+
expect(hook.editingPlaylist()).toBe(true);
|
|
71
|
+
dispose();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("exits playlist edit mode via handleCloseEdit", () => {
|
|
76
|
+
createRoot((dispose) => {
|
|
77
|
+
const hook = useSongState();
|
|
78
|
+
hook.handleEditPlaylist();
|
|
79
|
+
hook.handleCloseEdit();
|
|
80
|
+
expect(hook.editingPlaylist()).toBe(false);
|
|
81
|
+
expect(hook.isEditMode()).toBe(false);
|
|
82
|
+
dispose();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("exits playlist edit mode via setEditingPlaylist(false)", () => {
|
|
87
|
+
createRoot((dispose) => {
|
|
88
|
+
const hook = useSongState();
|
|
89
|
+
hook.handleEditPlaylist();
|
|
90
|
+
hook.setEditingPlaylist(false);
|
|
91
|
+
expect(hook.editingPlaylist()).toBe(false);
|
|
92
|
+
expect(hook.isEditMode()).toBe(false);
|
|
93
|
+
dispose();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("song edit mode", () => {
|
|
99
|
+
it("activates song edit mode with the correct song", () => {
|
|
100
|
+
createRoot((dispose) => {
|
|
101
|
+
const hook = useSongState();
|
|
102
|
+
hook.handleEditSong(mockSong);
|
|
103
|
+
expect(hook.editingSong()).toEqual(mockSong);
|
|
104
|
+
expect(hook.editingPlaylist()).toBe(false);
|
|
105
|
+
expect(hook.isEditMode()).toBe(true);
|
|
106
|
+
dispose();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps playlist edit open when entering song edit", () => {
|
|
111
|
+
createRoot((dispose) => {
|
|
112
|
+
const hook = useSongState();
|
|
113
|
+
hook.handleEditPlaylist();
|
|
114
|
+
hook.handleEditSong(mockSong);
|
|
115
|
+
expect(hook.editingPlaylist()).toBe(true);
|
|
116
|
+
expect(hook.editingSong()).toEqual(mockSong);
|
|
117
|
+
dispose();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("switches between songs without going through idle state", () => {
|
|
122
|
+
createRoot((dispose) => {
|
|
123
|
+
const hook = useSongState();
|
|
124
|
+
hook.handleEditSong(mockSong);
|
|
125
|
+
hook.handleEditSong(mockSong2);
|
|
126
|
+
expect(hook.editingSong()).toEqual(mockSong2);
|
|
127
|
+
expect(hook.isEditMode()).toBe(true);
|
|
128
|
+
dispose();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("exits song edit mode via handleCloseEdit", () => {
|
|
133
|
+
createRoot((dispose) => {
|
|
134
|
+
const hook = useSongState();
|
|
135
|
+
hook.handleEditSong(mockSong);
|
|
136
|
+
hook.handleCloseEdit();
|
|
137
|
+
expect(hook.editingSong()).toBeNull();
|
|
138
|
+
expect(hook.isEditMode()).toBe(false);
|
|
139
|
+
dispose();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("exits song edit mode via setEditingSong(null)", () => {
|
|
144
|
+
createRoot((dispose) => {
|
|
145
|
+
const hook = useSongState();
|
|
146
|
+
hook.handleEditSong(mockSong);
|
|
147
|
+
hook.setEditingSong(null);
|
|
148
|
+
expect(hook.editingSong()).toBeNull();
|
|
149
|
+
expect(hook.isEditMode()).toBe(false);
|
|
150
|
+
dispose();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("handleSongSaved", () => {
|
|
156
|
+
it("updates song in IDB and keeps editing the saved song", async () => {
|
|
157
|
+
const { updateSongInDoc } = await import("../services/playlistDocService.js");
|
|
158
|
+
let savedHook: ReturnType<typeof useSongState> | undefined;
|
|
159
|
+
createRoot((dispose) => {
|
|
160
|
+
savedHook = useSongState();
|
|
161
|
+
savedHook.handleEditSong(mockSong);
|
|
162
|
+
dispose();
|
|
163
|
+
});
|
|
164
|
+
// test the async call outside the root since createRoot disposes sync
|
|
165
|
+
const hook = (() => {
|
|
166
|
+
let h: ReturnType<typeof useSongState>;
|
|
167
|
+
createRoot(() => { h = useSongState(); h.handleEditSong(mockSong); });
|
|
168
|
+
return h!;
|
|
169
|
+
})();
|
|
170
|
+
const updatedSong = { ...mockSong, title: "updated title" };
|
|
171
|
+
await hook.handleSongSaved(updatedSong);
|
|
172
|
+
expect(updateSongInDoc).toHaveBeenCalledWith(updatedSong.playlistId, updatedSong.id, updatedSong);
|
|
173
|
+
// panel stays open with the saved values
|
|
174
|
+
expect(hook.editingSong()).toEqual(updatedSong);
|
|
175
|
+
expect(hook.isEditMode()).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("sets error when IDB update fails", async () => {
|
|
179
|
+
const { updateSongInDoc } = await import("../services/playlistDocService.js");
|
|
180
|
+
vi.mocked(updateSongInDoc).mockRejectedValueOnce(new Error("db error"));
|
|
181
|
+
let hook: ReturnType<typeof useSongState>;
|
|
182
|
+
createRoot(() => { hook = useSongState(); hook!.handleEditSong(mockSong); });
|
|
183
|
+
await hook!.handleSongSaved(mockSong);
|
|
184
|
+
expect(hook!.error()).toBeTruthy();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("isEditMode derived signal", () => {
|
|
189
|
+
it("is false when neither song nor playlist is being edited", () => {
|
|
190
|
+
createRoot((dispose) => {
|
|
191
|
+
const hook = useSongState();
|
|
192
|
+
expect(hook.isEditMode()).toBe(false);
|
|
193
|
+
dispose();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("is true when editing a song", () => {
|
|
198
|
+
createRoot((dispose) => {
|
|
199
|
+
const hook = useSongState();
|
|
200
|
+
hook.handleEditSong(mockSong);
|
|
201
|
+
expect(hook.isEditMode()).toBe(true);
|
|
202
|
+
dispose();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("is true when editing the playlist", () => {
|
|
207
|
+
createRoot((dispose) => {
|
|
208
|
+
const hook = useSongState();
|
|
209
|
+
hook.handleEditPlaylist();
|
|
210
|
+
expect(hook.isEditMode()).toBe(true);
|
|
211
|
+
dispose();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns to false after handleCloseEdit from song edit", () => {
|
|
216
|
+
createRoot((dispose) => {
|
|
217
|
+
const hook = useSongState();
|
|
218
|
+
hook.handleEditSong(mockSong);
|
|
219
|
+
hook.handleCloseEdit();
|
|
220
|
+
expect(hook.isEditMode()).toBe(false);
|
|
221
|
+
dispose();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns to false after handleCloseEdit from playlist edit", () => {
|
|
226
|
+
createRoot((dispose) => {
|
|
227
|
+
const hook = useSongState();
|
|
228
|
+
hook.handleEditPlaylist();
|
|
229
|
+
hook.handleCloseEdit();
|
|
230
|
+
expect(hook.isEditMode()).toBe(false);
|
|
231
|
+
dispose();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
|
|
2
|
+
import { createSignal, batch } from "solid-js";
|
|
3
|
+
import type { Song, Playlist } from "../types/playlist.js";
|
|
4
|
+
import { updateSongInDoc } from "../services/playlistDocService.js";
|
|
5
|
+
import { log } from "../utils/log.js";
|
|
6
|
+
import {
|
|
7
|
+
playSong,
|
|
8
|
+
playSongFromPlaylist,
|
|
9
|
+
togglePlayback,
|
|
10
|
+
audioState,
|
|
11
|
+
} from "../services/audioService.js";
|
|
12
|
+
|
|
13
|
+
export function useSongState() {
|
|
14
|
+
const [editingSong, setEditingSong] = createSignal<Song | null>(null);
|
|
15
|
+
const [editingPlaylist, setEditingPlaylist] = createSignal(false);
|
|
16
|
+
|
|
17
|
+
// true when any edit panel is open
|
|
18
|
+
const isEditMode = () => editingSong() !== null || editingPlaylist();
|
|
19
|
+
|
|
20
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
21
|
+
|
|
22
|
+
// note: does not clear playlist edit mode - the song edit panel can
|
|
23
|
+
// coexist below the playlist edit panel
|
|
24
|
+
const handleEditSong = (song: Song) => {
|
|
25
|
+
setEditingSong(song);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleEditPlaylist = () => {
|
|
29
|
+
setEditingSong(null);
|
|
30
|
+
setEditingPlaylist(true);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// batched so dependent effects see both signals cleared at once
|
|
34
|
+
// (otherwise clearing the song while playlist edit is still open would
|
|
35
|
+
// re-trigger the default-song effect and re-open the song panel)
|
|
36
|
+
const handleCloseEdit = () => {
|
|
37
|
+
batch(() => {
|
|
38
|
+
setEditingSong(null);
|
|
39
|
+
setEditingPlaylist(false);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// handle song update after editing - keeps the edit panel open and refreshes
|
|
44
|
+
// the editing song reference with the saved values
|
|
45
|
+
const handleSongSaved = async (updatedSong: Song) => {
|
|
46
|
+
try {
|
|
47
|
+
setError(null);
|
|
48
|
+
// song.playlistId is the docId for doc-backed songs
|
|
49
|
+
await updateSongInDoc(updatedSong.playlistId, updatedSong.id, updatedSong);
|
|
50
|
+
setEditingSong(updatedSong);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
log.error("song.save", "error saving song:", err);
|
|
53
|
+
setError("failed to save song changes");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handlePlaySong = async (song: Song, playlist?: Playlist) => {
|
|
58
|
+
try {
|
|
59
|
+
setError(null);
|
|
60
|
+
if (playlist) {
|
|
61
|
+
await playSongFromPlaylist(song, playlist);
|
|
62
|
+
} else {
|
|
63
|
+
await playSong(song);
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log.error("song.play", "error playing song:", err);
|
|
67
|
+
setError("failed to play song");
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handlePauseSong = async () => {
|
|
72
|
+
try {
|
|
73
|
+
setError(null);
|
|
74
|
+
await togglePlayback();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log.error("song.play", "error pausing song:", err);
|
|
77
|
+
setError("Failed to pause song");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const isSongPlaying = (songId: string) => {
|
|
82
|
+
const currentSong = audioState.currentSong();
|
|
83
|
+
return currentSong?.id === songId && audioState.isPlaying();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// is song currently selected (but maybe paused)
|
|
87
|
+
const isSongSelected = (songId: string) => {
|
|
88
|
+
const currentSong = audioState.currentSong();
|
|
89
|
+
return currentSong?.id === songId;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
editingSong,
|
|
94
|
+
editingPlaylist,
|
|
95
|
+
isEditMode,
|
|
96
|
+
error,
|
|
97
|
+
|
|
98
|
+
// setterz
|
|
99
|
+
setEditingSong,
|
|
100
|
+
setEditingPlaylist,
|
|
101
|
+
|
|
102
|
+
// actionz
|
|
103
|
+
handleEditSong,
|
|
104
|
+
handleEditPlaylist,
|
|
105
|
+
handleCloseEdit,
|
|
106
|
+
handleSongSaved,
|
|
107
|
+
handlePlaySong,
|
|
108
|
+
handlePauseSong,
|
|
109
|
+
|
|
110
|
+
// utilz
|
|
111
|
+
isSongPlaying,
|
|
112
|
+
isSongSelected,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
|
|
2
|
+
import { createSignal, onMount, onCleanup } from "solid-js";
|
|
3
|
+
|
|
4
|
+
export function useUIState() {
|
|
5
|
+
const [isMobile, setIsMobile] = createSignal(false);
|
|
6
|
+
|
|
7
|
+
const [isDragOver, setIsDragOver] = createSignal(false);
|
|
8
|
+
|
|
9
|
+
const [backgroundImageUrl, setBackgroundImageUrl] = createSignal<
|
|
10
|
+
string | null
|
|
11
|
+
>(null);
|
|
12
|
+
|
|
13
|
+
const [imageUrlCache] = createSignal(new Map<string, string>());
|
|
14
|
+
|
|
15
|
+
const checkMobile = () => {
|
|
16
|
+
const mobile = window.innerWidth < 900;
|
|
17
|
+
setIsMobile(mobile);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// window resize for mobile detection
|
|
21
|
+
const handleResize = () => {
|
|
22
|
+
checkMobile();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// escape key for closing modals/dialogs
|
|
26
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
27
|
+
if (e.key === "Escape") {
|
|
28
|
+
// this can be extended by components using this hook
|
|
29
|
+
return { key: e.key, preventDefault: () => e.preventDefault() };
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// init + cleanup for mobile detection
|
|
35
|
+
onMount(() => {
|
|
36
|
+
checkMobile();
|
|
37
|
+
window.addEventListener("resize", handleResize);
|
|
38
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
39
|
+
|
|
40
|
+
onCleanup(() => {
|
|
41
|
+
window.removeEventListener("resize", handleResize);
|
|
42
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// trash image URLs when component unmounts
|
|
47
|
+
onCleanup(() => {
|
|
48
|
+
const cache = imageUrlCache();
|
|
49
|
+
cache.forEach((url) => {
|
|
50
|
+
if (url.startsWith("blob:")) {
|
|
51
|
+
URL.revokeObjectURL(url);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
cache.clear();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
isMobile,
|
|
59
|
+
isDragOver,
|
|
60
|
+
backgroundImageUrl,
|
|
61
|
+
imageUrlCache,
|
|
62
|
+
|
|
63
|
+
// setterz
|
|
64
|
+
setIsMobile,
|
|
65
|
+
setIsDragOver,
|
|
66
|
+
setBackgroundImageUrl,
|
|
67
|
+
|
|
68
|
+
// utilz
|
|
69
|
+
checkMobile,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* @refresh reload */
|
|
2
|
+
import { render } from "solid-js/web";
|
|
3
|
+
import App from "./App";
|
|
4
|
+
|
|
5
|
+
const root = document.getElementById("root");
|
|
6
|
+
|
|
7
|
+
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?"
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// clear any existing loading content
|
|
14
|
+
if (root) {
|
|
15
|
+
root.innerHTML = "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render(() => <App />, root!);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// dev-only hook registrations for audio service.
|
|
2
|
+
//
|
|
3
|
+
// registers window.__seekTo, __triggerTrackEnd, __triggerAudioError.
|
|
4
|
+
// these are time-acceleration hooks, not transport mocks - they drive
|
|
5
|
+
// the real audio element without substituting any service boundary.
|
|
6
|
+
//
|
|
7
|
+
// only loaded in DEV builds (via src/dev-hooks.ts).
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
_devSeekTo,
|
|
11
|
+
_devTriggerTrackEnd,
|
|
12
|
+
_devTriggerAudioError,
|
|
13
|
+
audioState,
|
|
14
|
+
} from "./audioService.js";
|
|
15
|
+
|
|
16
|
+
export function registerAudioDevHooks(): void {
|
|
17
|
+
window.__seekTo = _devSeekTo;
|
|
18
|
+
window.__triggerTrackEnd = _devTriggerTrackEnd;
|
|
19
|
+
window.__triggerAudioError = _devTriggerAudioError;
|
|
20
|
+
// returns the title of the currently playing song, or null
|
|
21
|
+
window.__currentSong = () => audioState.currentSong()?.title ?? null;
|
|
22
|
+
}
|