@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,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
|
+
});
|