@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,201 @@
1
+
2
+ import { createSignal, createEffect, onCleanup } from "solid-js";
3
+ import type { Playlist, Song } from "../types/playlist.js";
4
+ import { getImageUrlForContext } from "../services/imageService.js";
5
+
6
+ export interface ImageWithMetadata {
7
+ url: string;
8
+ title: string;
9
+ type: "playlist" | "song";
10
+ id: string;
11
+ }
12
+
13
+ export function useImageModal() {
14
+ // modal state
15
+ const [showImageModal, setShowImageModal] = createSignal(false);
16
+ const [modalImageIndex, setModalImageIndex] = createSignal(0);
17
+ const [modalImages, setModalImages] = createSignal<ImageWithMetadata[]>([]);
18
+
19
+ // gen image list from playlist and songs
20
+ const generateImageList = (
21
+ playlist: Playlist | null,
22
+ playlistSongs: Song[] = []
23
+ ) => {
24
+ const images: ImageWithMetadata[] = [];
25
+
26
+ // try to add playlist cover image if available
27
+ if (playlist?.imageType || playlist?.imageFilePath) {
28
+ const playlistImageUrl = getImageUrlForContext(playlist, "modal");
29
+ if (playlistImageUrl) {
30
+ images.push({
31
+ url: playlistImageUrl,
32
+ title: playlist.title,
33
+ type: "playlist",
34
+ id: playlist.id,
35
+ });
36
+ }
37
+ }
38
+
39
+ // collect song images
40
+ playlistSongs.forEach((song) => {
41
+ if (song.imageType || song.imageFilePath) {
42
+ const songImageUrl = getImageUrlForContext(song, "modal");
43
+ if (songImageUrl) {
44
+ images.push({
45
+ url: songImageUrl,
46
+ title: song.title,
47
+ type: "song",
48
+ id: song.id,
49
+ });
50
+ }
51
+ }
52
+ });
53
+
54
+ return images;
55
+ };
56
+
57
+ const openImageModal = (
58
+ playlist: Playlist | null,
59
+ playlistSongs: Song[] = [],
60
+ startIndex: number = 0
61
+ ) => {
62
+ const images = generateImageList(playlist, playlistSongs);
63
+ if (images.length === 0) return;
64
+
65
+ setModalImages(images);
66
+ setModalImageIndex(Math.min(startIndex, images.length - 1));
67
+ setShowImageModal(true);
68
+ };
69
+
70
+ const closeImageModal = () => {
71
+ setShowImageModal(false);
72
+ setModalImageIndex(0);
73
+ setModalImages([]);
74
+ };
75
+
76
+ // navigate to next image
77
+ const handleNextImage = () => {
78
+ const images = modalImages();
79
+ if (images.length <= 1) return;
80
+
81
+ setModalImageIndex((prev) => (prev + 1) % images.length);
82
+ };
83
+
84
+ // navigate to previous image
85
+ const handlePrevImage = () => {
86
+ const images = modalImages();
87
+ if (images.length <= 1) return;
88
+
89
+ setModalImageIndex((prev) => (prev - 1 + images.length) % images.length);
90
+ };
91
+
92
+ // navigate to specific image (not used)
93
+ const goToImage = (index: number) => {
94
+ const images = modalImages();
95
+ if (index >= 0 && index < images.length) {
96
+ setModalImageIndex(index);
97
+ }
98
+ };
99
+
100
+ const getCurrentImageUrl = () => {
101
+ const images = modalImages();
102
+ const index = modalImageIndex();
103
+ return images[index]?.url || null;
104
+ };
105
+
106
+ const getCurrentImageMetadata = () => {
107
+ const images = modalImages();
108
+ const index = modalImageIndex();
109
+ return images[index] || null;
110
+ };
111
+
112
+ const getCurrentImageTitle = () => {
113
+ const images = modalImages();
114
+ const index = modalImageIndex();
115
+ return images[index]?.title || null;
116
+ };
117
+
118
+ const getImageCount = () => modalImages().length;
119
+
120
+ // image index 1-based for display
121
+ const getCurrentImageNumber = () => modalImageIndex() + 1;
122
+
123
+ const hasMultipleImages = () => modalImages().length > 1;
124
+
125
+ // keyboard navigation
126
+ const handleKeyDown = (e: KeyboardEvent) => {
127
+ if (!showImageModal()) return;
128
+
129
+ switch (e.key) {
130
+ case "Escape":
131
+ e.preventDefault();
132
+ closeImageModal();
133
+ break;
134
+ case "ArrowLeft":
135
+ e.preventDefault();
136
+ handlePrevImage();
137
+ break;
138
+ case "ArrowRight":
139
+ e.preventDefault();
140
+ handleNextImage();
141
+ break;
142
+ case "Home":
143
+ e.preventDefault();
144
+ goToImage(0);
145
+ break;
146
+ case "End":
147
+ e.preventDefault();
148
+ goToImage(getImageCount() - 1);
149
+ break;
150
+ default: {
151
+ // number keyz (1-9) to jump to specific images, cuz why not?!
152
+ const num = parseInt(e.key);
153
+ if (!isNaN(num) && num >= 1 && num <= 9) {
154
+ const targetIndex = num - 1;
155
+ if (targetIndex < getImageCount()) {
156
+ e.preventDefault();
157
+ goToImage(targetIndex);
158
+ }
159
+ }
160
+ break;
161
+ }
162
+ }
163
+ };
164
+
165
+ // set 'em up the keyboard event listenerz when modal is open
166
+ createEffect(() => {
167
+ if (showImageModal()) {
168
+ document.addEventListener("keydown", handleKeyDown);
169
+
170
+ onCleanup(() => {
171
+ document.removeEventListener("keydown", handleKeyDown);
172
+ });
173
+ }
174
+ });
175
+
176
+ return {
177
+ showImageModal,
178
+ modalImageIndex,
179
+ modalImages,
180
+
181
+ // setterz
182
+ setShowImageModal,
183
+ setModalImageIndex,
184
+
185
+ // actionz
186
+ openImageModal,
187
+ closeImageModal,
188
+ handleNextImage,
189
+ handlePrevImage,
190
+ goToImage,
191
+
192
+ // utilz
193
+ getCurrentImageUrl,
194
+ getCurrentImageMetadata,
195
+ getCurrentImageTitle,
196
+ getImageCount,
197
+ getCurrentImageNumber,
198
+ hasMultipleImages,
199
+ generateImageList,
200
+ };
201
+ }
@@ -0,0 +1,453 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { createRoot } from "solid-js";
3
+ import { usePlaylistManager } from "./usePlaylistManager.js";
4
+ import type { Playlist } from "../types/playlist.js";
5
+ import type { DocIndexEntry } from "../services/indexedDBService.js";
6
+
7
+ const mockPlaylist: Playlist = {
8
+ id: "test-playlist",
9
+ title: "Test Playlist",
10
+ description: "Test Description",
11
+ songIds: ["song1", "song2"],
12
+ createdAt: Date.now(),
13
+ updatedAt: Date.now(),
14
+ };
15
+
16
+ const mockDocEntry: DocIndexEntry = {
17
+ docId: "test-playlist",
18
+ title: "Test Playlist",
19
+ addedAt: Date.now(),
20
+ source: "local",
21
+ };
22
+
23
+ // keep a reference to the docIndex entries array that createDocIndexQuery returns
24
+ let docIndexEntries: DocIndexEntry[] = [mockDocEntry];
25
+
26
+ // Mock the services
27
+ vi.mock("../services/playlistDocService.js", () => ({
28
+ createPlaylist: vi.fn(),
29
+ updatePlaylist: vi.fn(),
30
+ deletePlaylist: vi.fn(),
31
+ addSongToPlaylist: vi.fn(),
32
+ deleteSong: vi.fn(),
33
+ reorderSongsInDoc: vi.fn(),
34
+ getSongsForPlaylist: vi.fn().mockResolvedValue([]),
35
+ getSongsFromHandle: vi.fn().mockResolvedValue([]),
36
+ getSongById: vi.fn(),
37
+ docToPlaylist: vi.fn(),
38
+ docToPlaylistAsync: vi.fn().mockResolvedValue({
39
+ id: "test-playlist",
40
+ title: "Test Playlist",
41
+ description: "Test Description",
42
+ songIds: ["song1", "song2"],
43
+ createdAt: Date.now(),
44
+ updatedAt: Date.now(),
45
+ }),
46
+ getSongsWithAudioData: vi.fn().mockResolvedValue([]),
47
+ }));
48
+
49
+ vi.mock("./createDocIndexQuery.js", () => ({
50
+ createDocIndexQuery: vi.fn(() => () => docIndexEntries),
51
+ }));
52
+
53
+ vi.mock("../services/automergeRepo.js", () => ({
54
+ findPlaylistDoc: vi.fn(async () => ({
55
+ doc: () => ({ title: "Test Playlist", songs: {}, songIds: [], peers: {} }),
56
+ on: vi.fn(),
57
+ off: vi.fn(),
58
+ })),
59
+ getRepo: vi.fn(),
60
+ }));
61
+
62
+ vi.mock("@freqhole/api-client/playlistz", () => ({
63
+ parsePlaylistDoc: vi.fn((doc: Record<string, unknown>) => doc),
64
+ emptyPlaylistDoc: vi.fn(),
65
+ }));
66
+
67
+ vi.mock("../services/fileProcessingService.js", () => ({
68
+ filterAudioFiles: vi.fn(),
69
+ }));
70
+
71
+ vi.mock("../services/playlistDownloadService.js", () => ({
72
+ parsePlaylistZip: vi.fn(),
73
+ downloadPlaylistAsZip: vi.fn(),
74
+ }));
75
+
76
+ vi.mock("../services/standaloneService.js", () => ({
77
+ initializeStandalonePlaylist: vi.fn(),
78
+ clearStandaloneLoadingProgress: vi.fn(),
79
+ }));
80
+
81
+ vi.mock("../services/offlineService.js", () => ({
82
+ initializeOfflineSupport: vi.fn(),
83
+ updatePWAManifest: vi.fn(),
84
+ cacheAudioFile: vi.fn(),
85
+ }));
86
+
87
+ vi.mock("../services/audioService.js", () => ({
88
+ audioState: {
89
+ currentSong: vi.fn(() => null),
90
+ currentPlaylist: vi.fn(() => null),
91
+ },
92
+ stop: vi.fn(),
93
+ refreshPlaylistQueue: vi.fn(),
94
+ }));
95
+
96
+ vi.mock("../services/imageService.js", () => ({
97
+ getImageUrlForContext: vi.fn(),
98
+ }));
99
+
100
+ vi.mock("../services/indexedDBService.js", () => ({
101
+ loadSetting: vi.fn().mockResolvedValue(null),
102
+ saveSetting: vi.fn().mockResolvedValue(undefined),
103
+ }));
104
+
105
+ describe("usePlaylistManager consolidated delete operations", () => {
106
+ let dispose: () => void;
107
+ let hook: ReturnType<typeof usePlaylistManager>;
108
+
109
+ beforeEach(async () => {
110
+ vi.clearAllMocks();
111
+ docIndexEntries = [mockDocEntry];
112
+
113
+ // re-initialize mock implementations cleared by vi.clearAllMocks()
114
+ const docSvc = await import("../services/playlistDocService.js");
115
+ const repoSvc = await import("../services/automergeRepo.js");
116
+ const idbSvc = await import("../services/indexedDBService.js");
117
+ const fhClient = await import("@freqhole/api-client/playlistz");
118
+ const docIndexQry = await import("./createDocIndexQuery.js");
119
+
120
+ vi.mocked(docIndexQry.createDocIndexQuery).mockReturnValue(() => docIndexEntries as never);
121
+
122
+ vi.mocked(repoSvc.findPlaylistDoc).mockResolvedValue({
123
+ doc: () => ({ title: "Test Playlist", songs: {}, songIds: [], peers: {} }),
124
+ on: vi.fn(),
125
+ off: vi.fn(),
126
+ } as never);
127
+
128
+ vi.mocked(docSvc.docToPlaylistAsync).mockResolvedValue({
129
+ id: "test-playlist",
130
+ title: "Test Playlist",
131
+ description: "Test Description",
132
+ songIds: ["song1", "song2"],
133
+ createdAt: Date.now(),
134
+ updatedAt: Date.now(),
135
+ } as never);
136
+
137
+ vi.mocked(docSvc.getSongsFromHandle).mockResolvedValue([] as never);
138
+ vi.mocked(idbSvc.loadSetting).mockResolvedValue(null as never);
139
+ vi.mocked(fhClient.parsePlaylistDoc).mockImplementation((doc: unknown) => doc as never);
140
+
141
+ createRoot((disposeFn) => {
142
+ dispose = disposeFn;
143
+ hook = usePlaylistManager();
144
+ });
145
+
146
+ // wait for deferred effects to run (syncPlaylistsFromDocIndex, on-select, etc.)
147
+ await new Promise((resolve) => setTimeout(resolve, 0));
148
+ await new Promise((resolve) => setTimeout(resolve, 0));
149
+ await Promise.resolve();
150
+ await Promise.resolve();
151
+
152
+ // ensure the selected playlist is populated for tests that rely on it
153
+ hook.setSelectedPlaylist(mockPlaylist);
154
+ });
155
+
156
+ afterEach(() => {
157
+ if (dispose) {
158
+ dispose();
159
+ }
160
+ });
161
+
162
+ describe("playlist deletion", () => {
163
+ it("should delete playlist and clear selectedPlaylist", async () => {
164
+ const { deletePlaylist } = await import(
165
+ "../services/playlistDocService.js"
166
+ );
167
+
168
+ // Mock successful deletion
169
+ vi.mocked(deletePlaylist).mockResolvedValue();
170
+
171
+ // Select playlist
172
+ hook.setSelectedPlaylist(mockPlaylist);
173
+ expect(hook.selectedPlaylist()).toBeTruthy();
174
+ expect(hook.selectedPlaylist()?.id).toBe("test-playlist");
175
+
176
+ // Delete playlist
177
+ await hook.handleDeletePlaylist();
178
+
179
+ // Playlist should be cleared and service called
180
+ expect(hook.selectedPlaylist()).toBeNull();
181
+ expect(deletePlaylist).toHaveBeenCalledWith("test-playlist");
182
+ });
183
+
184
+ it("should handle deletion errors gracefully", async () => {
185
+ const { deletePlaylist } = await import(
186
+ "../services/playlistDocService.js"
187
+ );
188
+
189
+ // Mock service error
190
+ vi.mocked(deletePlaylist).mockRejectedValue(new Error("Delete failed"));
191
+
192
+ hook.setSelectedPlaylist(mockPlaylist);
193
+
194
+ await hook.handleDeletePlaylist();
195
+
196
+ expect(hook.error()).toBe("failed to delete playlist!");
197
+ expect(hook.selectedPlaylist()).toBeTruthy(); // Should remain selected on error
198
+ });
199
+
200
+ it("should stop playback if deleted playlist contains currently playing song", async () => {
201
+ const { deletePlaylist } = await import(
202
+ "../services/playlistDocService.js"
203
+ );
204
+ const { audioState, stop } = await import("../services/audioService.js");
205
+
206
+ vi.mocked(deletePlaylist).mockResolvedValue();
207
+
208
+ // Mock that a song from this playlist is currently playing
209
+ vi.mocked(audioState.currentSong).mockReturnValue({
210
+ id: "song1",
211
+ title: "Song 1",
212
+ artist: "Artist 1",
213
+ album: "Album 1",
214
+ duration: 180,
215
+ position: 0,
216
+ playlistId: "test-playlist", // Same as the playlist being deleted
217
+ fileSize: 1024,
218
+ mimeType: "audio/mp3",
219
+ originalFilename: "song1.mp3",
220
+ createdAt: Date.now(),
221
+ updatedAt: Date.now(),
222
+ });
223
+
224
+ hook.setSelectedPlaylist(mockPlaylist);
225
+
226
+ await hook.handleDeletePlaylist();
227
+
228
+ expect(stop).toHaveBeenCalled();
229
+ expect(deletePlaylist).toHaveBeenCalledWith("test-playlist");
230
+ expect(hook.selectedPlaylist()).toBeNull();
231
+ });
232
+
233
+ it("should not stop playback if deleted playlist does not contain currently playing song", async () => {
234
+ const { deletePlaylist } = await import(
235
+ "../services/playlistDocService.js"
236
+ );
237
+ const { audioState, stop } = await import("../services/audioService.js");
238
+
239
+ vi.mocked(deletePlaylist).mockResolvedValue();
240
+
241
+ // Mock that a song from a different playlist is currently playing
242
+ vi.mocked(audioState.currentSong).mockReturnValue({
243
+ id: "song1",
244
+ title: "Song 1",
245
+ artist: "Artist 1",
246
+ album: "Album 1",
247
+ duration: 180,
248
+ position: 0,
249
+ playlistId: "different-playlist", // Different from the playlist being deleted
250
+ fileSize: 1024,
251
+ mimeType: "audio/mp3",
252
+ originalFilename: "song1.mp3",
253
+ createdAt: Date.now(),
254
+ updatedAt: Date.now(),
255
+ });
256
+
257
+ hook.setSelectedPlaylist(mockPlaylist);
258
+
259
+ await hook.handleDeletePlaylist();
260
+
261
+ expect(stop).not.toHaveBeenCalled();
262
+ expect(deletePlaylist).toHaveBeenCalledWith("test-playlist");
263
+ expect(hook.selectedPlaylist()).toBeNull();
264
+ });
265
+ });
266
+
267
+ describe("song removal", () => {
268
+ it("should remove song from playlist", async () => {
269
+ const { deleteSong } = await import(
270
+ "../services/playlistDocService.js"
271
+ );
272
+
273
+ vi.mocked(deleteSong).mockResolvedValue();
274
+
275
+ hook.setSelectedPlaylist(mockPlaylist);
276
+
277
+ await hook.handleRemoveSong("song1");
278
+
279
+ expect(deleteSong).toHaveBeenCalledWith(
280
+ "test-playlist",
281
+ "song1"
282
+ );
283
+ expect(hook.error()).toBeNull();
284
+ });
285
+
286
+ it("should handle song removal errors", async () => {
287
+ const { deleteSong } = await import(
288
+ "../services/playlistDocService.js"
289
+ );
290
+
291
+ vi.mocked(deleteSong).mockRejectedValue(
292
+ new Error("Remove failed")
293
+ );
294
+
295
+ hook.setSelectedPlaylist(mockPlaylist);
296
+
297
+ await hook.handleRemoveSong("song1");
298
+
299
+ expect(hook.error()).toBe("failed to remove song from playlist!");
300
+ });
301
+ });
302
+
303
+ describe("song deletion side effects", () => {
304
+ it("should close edit modal when onClose callback is provided", async () => {
305
+ const { deleteSong } = await import(
306
+ "../services/playlistDocService.js"
307
+ );
308
+
309
+ vi.mocked(deleteSong).mockResolvedValue();
310
+
311
+ hook.setSelectedPlaylist(mockPlaylist);
312
+
313
+ const mockOnClose = vi.fn();
314
+
315
+ await hook.handleRemoveSong("song1", mockOnClose);
316
+
317
+ expect(deleteSong).toHaveBeenCalledWith(
318
+ "test-playlist",
319
+ "song1"
320
+ );
321
+ expect(mockOnClose).toHaveBeenCalled();
322
+ });
323
+
324
+ it("should work without onClose callback for regular delete operations", async () => {
325
+ const { deleteSong } = await import(
326
+ "../services/playlistDocService.js"
327
+ );
328
+
329
+ vi.mocked(deleteSong).mockResolvedValue();
330
+
331
+ hook.setSelectedPlaylist(mockPlaylist);
332
+
333
+ // Should work without callback (SongRow delete button case)
334
+ await hook.handleRemoveSong("song1");
335
+
336
+ expect(deleteSong).toHaveBeenCalledWith(
337
+ "test-playlist",
338
+ "song1"
339
+ );
340
+ expect(hook.error()).toBeNull();
341
+ });
342
+
343
+ it("should stop playback if deleted song is currently playing", async () => {
344
+ const { deleteSong } = await import(
345
+ "../services/playlistDocService.js"
346
+ );
347
+ const { audioState, stop } = await import("../services/audioService.js");
348
+
349
+ vi.mocked(deleteSong).mockResolvedValue();
350
+
351
+ // Mock that song1 is currently playing
352
+ vi.mocked(audioState.currentSong).mockReturnValue({
353
+ id: "song1",
354
+ title: "Song 1",
355
+ artist: "Artist 1",
356
+ album: "Album 1",
357
+ duration: 180,
358
+ position: 0,
359
+ playlistId: "test-playlist",
360
+ fileSize: 1024,
361
+ mimeType: "audio/mp3",
362
+ originalFilename: "song1.mp3",
363
+ createdAt: Date.now(),
364
+ updatedAt: Date.now(),
365
+ });
366
+
367
+ hook.setSelectedPlaylist(mockPlaylist);
368
+
369
+ await hook.handleRemoveSong("song1");
370
+
371
+ expect(stop).toHaveBeenCalled();
372
+ expect(deleteSong).toHaveBeenCalledWith(
373
+ "test-playlist",
374
+ "song1"
375
+ );
376
+ });
377
+
378
+ it("should not stop playback if deleted song is not currently playing", async () => {
379
+ const { deleteSong } = await import(
380
+ "../services/playlistDocService.js"
381
+ );
382
+ const { audioState, stop } = await import("../services/audioService.js");
383
+
384
+ vi.mocked(deleteSong).mockResolvedValue();
385
+
386
+ // Mock that song2 is currently playing (different from deleted song)
387
+ vi.mocked(audioState.currentSong).mockReturnValue({
388
+ id: "song2",
389
+ title: "Song 2",
390
+ artist: "Artist 2",
391
+ album: "Album 2",
392
+ duration: 200,
393
+ position: 1,
394
+ playlistId: "test-playlist",
395
+ fileSize: 2048,
396
+ mimeType: "audio/mp3",
397
+ originalFilename: "song2.mp3",
398
+ createdAt: Date.now(),
399
+ updatedAt: Date.now(),
400
+ });
401
+
402
+ hook.setSelectedPlaylist(mockPlaylist);
403
+
404
+ await hook.handleRemoveSong("song1");
405
+
406
+ expect(stop).not.toHaveBeenCalled();
407
+ expect(deleteSong).toHaveBeenCalledWith(
408
+ "test-playlist",
409
+ "song1"
410
+ );
411
+ });
412
+ });
413
+
414
+ describe("consolidated operations working correctly", () => {
415
+ it("should demonstrate that delete operations now work with unified state", async () => {
416
+ const { deletePlaylist, deleteSong } = await import(
417
+ "../services/playlistDocService.js"
418
+ );
419
+
420
+ vi.mocked(deletePlaylist).mockResolvedValue();
421
+ vi.mocked(deleteSong).mockResolvedValue();
422
+
423
+ // All operations now use the same hook, so state is unified
424
+ hook.setSelectedPlaylist(mockPlaylist);
425
+
426
+ // Song removal should work
427
+ await hook.handleRemoveSong("song1");
428
+ expect(deleteSong).toHaveBeenCalledWith(
429
+ "test-playlist",
430
+ "song1"
431
+ );
432
+
433
+ // Playlist deletion should work
434
+ await hook.handleDeletePlaylist();
435
+ expect(deletePlaylist).toHaveBeenCalledWith("test-playlist");
436
+ expect(hook.selectedPlaylist()).toBeNull();
437
+ });
438
+
439
+ it("should have all necessary operations consolidated", () => {
440
+ // Verify the hook exposes all needed operations
441
+ expect(typeof hook.handleDeletePlaylist).toBe("function");
442
+ expect(typeof hook.handleRemoveSong).toBe("function");
443
+ expect(typeof hook.handleReorderSongs).toBe("function");
444
+ expect(typeof hook.handlePlaylistUpdate).toBe("function");
445
+ expect(typeof hook.handleDownloadPlaylist).toBe("function");
446
+ expect(typeof hook.handleCachePlaylist).toBe("function");
447
+
448
+ // And all the UI state
449
+ expect(typeof hook.showDeleteConfirm).toBe("function");
450
+ expect(typeof hook.setShowDeleteConfirm).toBe("function");
451
+ });
452
+ });
453
+ });