@freqhole/playlistz 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. package/tsconfig.json +43 -0
@@ -0,0 +1,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
+ }