@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,519 @@
|
|
|
1
|
+
|
|
2
|
+
import {
|
|
3
|
+
createSignal,
|
|
4
|
+
Show,
|
|
5
|
+
onMount,
|
|
6
|
+
onCleanup,
|
|
7
|
+
createEffect,
|
|
8
|
+
For,
|
|
9
|
+
} from "solid-js";
|
|
10
|
+
import {
|
|
11
|
+
deletePlaylist,
|
|
12
|
+
setPlaylistCoverImage,
|
|
13
|
+
clearPlaylistCoverImage,
|
|
14
|
+
} from "../services/playlistDocService.js";
|
|
15
|
+
import {
|
|
16
|
+
processPlaylistCover,
|
|
17
|
+
validateImageFile,
|
|
18
|
+
createImageUrlFromData,
|
|
19
|
+
getImageUrlForContext,
|
|
20
|
+
} from "../services/imageService.js";
|
|
21
|
+
import { downloadPlaylistAsZip } from "../services/playlistDownloadService.js";
|
|
22
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
23
|
+
|
|
24
|
+
interface PlaylistCoverModalProps {
|
|
25
|
+
playlist: Playlist;
|
|
26
|
+
playlistSongs: Song[];
|
|
27
|
+
isOpen: boolean;
|
|
28
|
+
onClose: () => void;
|
|
29
|
+
onSave: (updatedPlaylist: Playlist) => void;
|
|
30
|
+
onDelete?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function PlaylistCoverModal(props: PlaylistCoverModalProps) {
|
|
34
|
+
const [selectedImageData, setSelectedImageData] = createSignal<
|
|
35
|
+
ArrayBuffer | undefined
|
|
36
|
+
>();
|
|
37
|
+
const [selectedThumbnailData, setSelectedThumbnailData] = createSignal<
|
|
38
|
+
ArrayBuffer | undefined
|
|
39
|
+
>();
|
|
40
|
+
const [selectedImageType, setSelectedImageType] = createSignal<
|
|
41
|
+
string | undefined
|
|
42
|
+
>();
|
|
43
|
+
const [selectedImageUrl, setSelectedImageUrl] = createSignal<
|
|
44
|
+
string | undefined
|
|
45
|
+
>();
|
|
46
|
+
const [isLoading, setIsLoading] = createSignal(false);
|
|
47
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
48
|
+
const [isDownloading, setIsDownloading] = createSignal(false);
|
|
49
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
|
|
50
|
+
|
|
51
|
+
onMount(() => {
|
|
52
|
+
if (props.isOpen && props.playlist) {
|
|
53
|
+
if (props.playlist.imageData && props.playlist.imageType) {
|
|
54
|
+
setSelectedImageData(props.playlist.imageData);
|
|
55
|
+
setSelectedThumbnailData(props.playlist.thumbnailData);
|
|
56
|
+
setSelectedImageType(props.playlist.imageType);
|
|
57
|
+
// temporary display URL using thumbnail if available, otherwise full size
|
|
58
|
+
const displayData =
|
|
59
|
+
props.playlist.thumbnailData || props.playlist.imageData;
|
|
60
|
+
const url = createImageUrlFromData(
|
|
61
|
+
displayData,
|
|
62
|
+
props.playlist.imageType
|
|
63
|
+
);
|
|
64
|
+
setSelectedImageUrl(url);
|
|
65
|
+
} else if (props.playlist.imageFilePath) {
|
|
66
|
+
// standalone mode: images are file paths, not in-memory buffers
|
|
67
|
+
setSelectedImageType(props.playlist.imageType);
|
|
68
|
+
setSelectedImageUrl(props.playlist.imageFilePath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const handleImageUpload = async (event: Event) => {
|
|
74
|
+
const input = event.target as HTMLInputElement;
|
|
75
|
+
const file = input.files?.[0];
|
|
76
|
+
|
|
77
|
+
if (!file) return;
|
|
78
|
+
|
|
79
|
+
const validation = validateImageFile(file);
|
|
80
|
+
if (!validation.valid) {
|
|
81
|
+
setError(validation.error || "invalid image file");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
setIsLoading(true);
|
|
87
|
+
setError(null);
|
|
88
|
+
|
|
89
|
+
const result = await processPlaylistCover(file);
|
|
90
|
+
|
|
91
|
+
if (result.success && result.thumbnailData && result.imageData) {
|
|
92
|
+
// trash previous URL if exists
|
|
93
|
+
const prevUrl = selectedImageUrl();
|
|
94
|
+
if (prevUrl) {
|
|
95
|
+
URL.revokeObjectURL(prevUrl);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setSelectedImageData(result.imageData);
|
|
99
|
+
setSelectedThumbnailData(result.thumbnailData);
|
|
100
|
+
setSelectedImageType(file.type);
|
|
101
|
+
|
|
102
|
+
const newUrl = createImageUrlFromData(result.thumbnailData, file.type);
|
|
103
|
+
|
|
104
|
+
setSelectedImageUrl(newUrl);
|
|
105
|
+
} else {
|
|
106
|
+
setError(result.error || "failed to process image");
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
setError("error uploading image");
|
|
110
|
+
console.error("image upload error:", err);
|
|
111
|
+
} finally {
|
|
112
|
+
setIsLoading(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleDownloadPlaylist = async () => {
|
|
117
|
+
setIsDownloading(true);
|
|
118
|
+
try {
|
|
119
|
+
await downloadPlaylistAsZip(props.playlist, {
|
|
120
|
+
includeMetadata: true,
|
|
121
|
+
includeImages: true,
|
|
122
|
+
generateM3U: true,
|
|
123
|
+
includeHTML: true,
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
setError("failed to download playlist");
|
|
127
|
+
console.error("download error:", err);
|
|
128
|
+
} finally {
|
|
129
|
+
setIsDownloading(false);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleDeletePlaylist = async () => {
|
|
134
|
+
try {
|
|
135
|
+
setIsLoading(true);
|
|
136
|
+
setError(null);
|
|
137
|
+
await deletePlaylist(props.playlist.id);
|
|
138
|
+
setShowDeleteConfirm(false);
|
|
139
|
+
props.onDelete?.();
|
|
140
|
+
props.onClose();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
setError("failed to delete playlist");
|
|
143
|
+
console.error("delete error:", err);
|
|
144
|
+
} finally {
|
|
145
|
+
setIsLoading(false);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleSave = async () => {
|
|
150
|
+
try {
|
|
151
|
+
setIsLoading(true);
|
|
152
|
+
setError(null);
|
|
153
|
+
|
|
154
|
+
const updates = {
|
|
155
|
+
imageData: selectedImageData(),
|
|
156
|
+
thumbnailData: selectedThumbnailData(),
|
|
157
|
+
imageType: selectedImageType(),
|
|
158
|
+
updatedAt: Date.now(),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const imageData = selectedImageData();
|
|
162
|
+
const imageType = selectedImageType();
|
|
163
|
+
if (imageData && imageType) {
|
|
164
|
+
await setPlaylistCoverImage(props.playlist.id, imageData, imageType);
|
|
165
|
+
} else {
|
|
166
|
+
await clearPlaylistCoverImage(props.playlist.id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// create updated playlist object, removing old image property if it exists
|
|
170
|
+
const { image: _image, ...playlistWithoutOldImage } =
|
|
171
|
+
props.playlist as Playlist & { image?: unknown };
|
|
172
|
+
const updatedPlaylist: Playlist = {
|
|
173
|
+
...playlistWithoutOldImage,
|
|
174
|
+
...updates,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
props.onSave(updatedPlaylist);
|
|
178
|
+
props.onClose();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
setError("failed to save");
|
|
181
|
+
console.error("save error:", err);
|
|
182
|
+
} finally {
|
|
183
|
+
setIsLoading(false);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const handleCancel = () => {
|
|
188
|
+
// trash any temporary URLs
|
|
189
|
+
const url = selectedImageUrl();
|
|
190
|
+
if (url) {
|
|
191
|
+
URL.revokeObjectURL(url);
|
|
192
|
+
}
|
|
193
|
+
setError(null);
|
|
194
|
+
setShowDeleteConfirm(false);
|
|
195
|
+
props.onClose();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const handleRemoveImage = () => {
|
|
199
|
+
const url = selectedImageUrl();
|
|
200
|
+
if (url) {
|
|
201
|
+
URL.revokeObjectURL(url);
|
|
202
|
+
}
|
|
203
|
+
setSelectedImageData(undefined);
|
|
204
|
+
setSelectedThumbnailData(undefined);
|
|
205
|
+
setSelectedImageType(undefined);
|
|
206
|
+
setSelectedImageUrl(undefined);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
createEffect(() => {
|
|
210
|
+
if (props.isOpen) {
|
|
211
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
212
|
+
if (e.key === "Escape") {
|
|
213
|
+
handleCancel();
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
218
|
+
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<Show when={props.isOpen}>
|
|
224
|
+
{(() => {
|
|
225
|
+
const songsWithArt = props.playlistSongs.filter(
|
|
226
|
+
(song) => song.imageType || song.imageFilePath
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4">
|
|
231
|
+
<div class="bg-gray-900 shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
|
232
|
+
{/* header */}
|
|
233
|
+
<div class="flex items-center justify-between p-6 border-b border-gray-700">
|
|
234
|
+
<h2 class="text-xl font-bold text-white font-mono">
|
|
235
|
+
cover<span class="text-magenta-500">z</span>
|
|
236
|
+
</h2>
|
|
237
|
+
<button
|
|
238
|
+
onClick={handleCancel}
|
|
239
|
+
class="text-gray-400 hover:text-white p-1 rounded"
|
|
240
|
+
disabled={isLoading()}
|
|
241
|
+
>
|
|
242
|
+
<svg
|
|
243
|
+
class="w-6 h-6"
|
|
244
|
+
fill="none"
|
|
245
|
+
stroke="currentColor"
|
|
246
|
+
viewBox="0 0 24 24"
|
|
247
|
+
>
|
|
248
|
+
<path
|
|
249
|
+
stroke-linecap="round"
|
|
250
|
+
stroke-linejoin="round"
|
|
251
|
+
stroke-width="2"
|
|
252
|
+
d="M6 18L18 6M6 6l12 12"
|
|
253
|
+
/>
|
|
254
|
+
</svg>
|
|
255
|
+
</button>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{/* content */}
|
|
259
|
+
<div class="p-6 space-y-6">
|
|
260
|
+
<div class="flex items-center gap-4">
|
|
261
|
+
<div class="w-25 h-25 overflow-hidden bg-gray-700 flex items-center justify-center">
|
|
262
|
+
<Show
|
|
263
|
+
when={selectedImageUrl()}
|
|
264
|
+
fallback={
|
|
265
|
+
<svg
|
|
266
|
+
class="w-8 h-8 text-gray-400"
|
|
267
|
+
fill="none"
|
|
268
|
+
stroke="currentColor"
|
|
269
|
+
viewBox="0 0 24 24"
|
|
270
|
+
>
|
|
271
|
+
<path
|
|
272
|
+
stroke-linecap="round"
|
|
273
|
+
stroke-linejoin="round"
|
|
274
|
+
stroke-width="2"
|
|
275
|
+
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
276
|
+
/>
|
|
277
|
+
</svg>
|
|
278
|
+
}
|
|
279
|
+
>
|
|
280
|
+
<img
|
|
281
|
+
src={selectedImageUrl()}
|
|
282
|
+
alt="playlist cover"
|
|
283
|
+
class="w-full h-full object-cover"
|
|
284
|
+
/>
|
|
285
|
+
</Show>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div class="flex-1 space-y-2">
|
|
289
|
+
<input
|
|
290
|
+
type="file"
|
|
291
|
+
accept="image/*"
|
|
292
|
+
onChange={handleImageUpload}
|
|
293
|
+
disabled={isLoading()}
|
|
294
|
+
class="hidden"
|
|
295
|
+
id="cover-upload"
|
|
296
|
+
/>
|
|
297
|
+
<label
|
|
298
|
+
for="cover-upload"
|
|
299
|
+
class="block w-full px-4 py-3 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white cursor-pointer text-center font-medium transition-colors"
|
|
300
|
+
>
|
|
301
|
+
upload image
|
|
302
|
+
</label>
|
|
303
|
+
|
|
304
|
+
<Show when={selectedImageData()}>
|
|
305
|
+
<button
|
|
306
|
+
onClick={handleRemoveImage}
|
|
307
|
+
disabled={isLoading()}
|
|
308
|
+
class="block w-full px-4 py-3 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-medium transition-colors"
|
|
309
|
+
>
|
|
310
|
+
remove cover image
|
|
311
|
+
</button>
|
|
312
|
+
</Show>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{/* playlist actions */}
|
|
317
|
+
<div class="space-y-3">
|
|
318
|
+
{/* download playlist */}
|
|
319
|
+
<Show when={window.location.protocol !== "file:"}>
|
|
320
|
+
<button
|
|
321
|
+
onClick={handleDownloadPlaylist}
|
|
322
|
+
disabled={isDownloading() || isLoading()}
|
|
323
|
+
class="w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-400 text-white font-medium transition-colors flex items-center justify-center gap-2"
|
|
324
|
+
>
|
|
325
|
+
<Show
|
|
326
|
+
when={!isDownloading()}
|
|
327
|
+
fallback={
|
|
328
|
+
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
329
|
+
}
|
|
330
|
+
>
|
|
331
|
+
<svg
|
|
332
|
+
class="w-4 h-4"
|
|
333
|
+
fill="none"
|
|
334
|
+
stroke="currentColor"
|
|
335
|
+
viewBox="0 0 24 24"
|
|
336
|
+
>
|
|
337
|
+
<path
|
|
338
|
+
stroke-linecap="round"
|
|
339
|
+
stroke-linejoin="round"
|
|
340
|
+
stroke-width="2"
|
|
341
|
+
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
342
|
+
/>
|
|
343
|
+
</svg>
|
|
344
|
+
</Show>
|
|
345
|
+
{isDownloading() ? "downloading..." : "download playlist"}
|
|
346
|
+
</button>
|
|
347
|
+
</Show>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{/* playlist info */}
|
|
351
|
+
<div class="bg-gray-800 p-4">
|
|
352
|
+
<h3 class="text-sm font-medium text-gray-300 mb-2">
|
|
353
|
+
playlist information
|
|
354
|
+
</h3>
|
|
355
|
+
<div class="text-sm text-gray-400 space-y-1">
|
|
356
|
+
<div>title: {props.playlist.title}</div>
|
|
357
|
+
<div>id: {props.playlist.id}</div>
|
|
358
|
+
<div>rev: {props.playlist.rev || 0}</div>
|
|
359
|
+
<div>songz: {props.playlist.songIds.length}</div>
|
|
360
|
+
<div>with album art: {songsWithArt.length}</div>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{/* songz with album art preview */}
|
|
365
|
+
<Show when={songsWithArt.length > 0}>
|
|
366
|
+
<div>
|
|
367
|
+
<div class="grid grid-cols-4 gap-3">
|
|
368
|
+
<For each={songsWithArt}>
|
|
369
|
+
{(song) => (
|
|
370
|
+
<button
|
|
371
|
+
onClick={() => {
|
|
372
|
+
// #TODO: this needz to be updated to work with ArrayBuffer data
|
|
373
|
+
// setError(
|
|
374
|
+
// "selecting from song imagez not yet implemented with new image storage"
|
|
375
|
+
// );
|
|
376
|
+
}}
|
|
377
|
+
disabled={isLoading()}
|
|
378
|
+
class="aspect-square overflow-hidden bg-gray-700" //hover:ring-2 hover:ring-magenta-500 transition-all
|
|
379
|
+
title={`${song.title} - ${song.artist}`}
|
|
380
|
+
>
|
|
381
|
+
<Show
|
|
382
|
+
when={song.imageType || song.imageFilePath}
|
|
383
|
+
fallback={
|
|
384
|
+
<div class="w-full h-full flex items-center justify-center">
|
|
385
|
+
<svg
|
|
386
|
+
class="w-6 h-6 text-gray-400"
|
|
387
|
+
fill="none"
|
|
388
|
+
stroke="currentColor"
|
|
389
|
+
viewBox="0 0 24 24"
|
|
390
|
+
>
|
|
391
|
+
<path
|
|
392
|
+
stroke-linecap="round"
|
|
393
|
+
stroke-linejoin="round"
|
|
394
|
+
stroke-width="2"
|
|
395
|
+
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
396
|
+
/>
|
|
397
|
+
</svg>
|
|
398
|
+
</div>
|
|
399
|
+
}
|
|
400
|
+
>
|
|
401
|
+
<img
|
|
402
|
+
src={
|
|
403
|
+
getImageUrlForContext(song, "thumbnail") ?? ""
|
|
404
|
+
}
|
|
405
|
+
alt={song.title}
|
|
406
|
+
class="w-full h-full object-cover"
|
|
407
|
+
/>
|
|
408
|
+
</Show>
|
|
409
|
+
</button>
|
|
410
|
+
)}
|
|
411
|
+
</For>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</Show>
|
|
415
|
+
|
|
416
|
+
{/* delete playlist */}
|
|
417
|
+
<div class="space-y-3">
|
|
418
|
+
<Show
|
|
419
|
+
when={!showDeleteConfirm()}
|
|
420
|
+
fallback={
|
|
421
|
+
<div class="bg-red-900 bg-opacity-30 border border-red-500 p-4 space-y-3">
|
|
422
|
+
<p class="text-white text-sm">
|
|
423
|
+
are you sure you want to delete this playlist? this
|
|
424
|
+
action cannot be undone.
|
|
425
|
+
</p>
|
|
426
|
+
<div class="flex gap-2">
|
|
427
|
+
<button
|
|
428
|
+
onClick={handleDeletePlaylist}
|
|
429
|
+
disabled={isLoading()}
|
|
430
|
+
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-medium transition-colors"
|
|
431
|
+
>
|
|
432
|
+
yes, delete
|
|
433
|
+
</button>
|
|
434
|
+
<button
|
|
435
|
+
onClick={() => setShowDeleteConfirm(false)}
|
|
436
|
+
disabled={isLoading()}
|
|
437
|
+
class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white font-medium transition-colors"
|
|
438
|
+
>
|
|
439
|
+
cancel
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
}
|
|
444
|
+
>
|
|
445
|
+
<button
|
|
446
|
+
onClick={() => setShowDeleteConfirm(true)}
|
|
447
|
+
disabled={isLoading()}
|
|
448
|
+
class="w-full px-4 py-3 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-medium transition-colors flex items-center justify-center gap-2"
|
|
449
|
+
>
|
|
450
|
+
<svg
|
|
451
|
+
class="w-4 h-4"
|
|
452
|
+
fill="none"
|
|
453
|
+
stroke="currentColor"
|
|
454
|
+
viewBox="0 0 24 24"
|
|
455
|
+
>
|
|
456
|
+
<path
|
|
457
|
+
stroke-linecap="round"
|
|
458
|
+
stroke-linejoin="round"
|
|
459
|
+
stroke-width="2"
|
|
460
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
461
|
+
/>
|
|
462
|
+
</svg>
|
|
463
|
+
delete playlist
|
|
464
|
+
</button>
|
|
465
|
+
</Show>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
{/* error message */}
|
|
469
|
+
<Show when={error()}>
|
|
470
|
+
<div class="bg-red-900 bg-opacity-30 border border-red-500 p-3">
|
|
471
|
+
<div class="text-red-400 text-sm">{error()}</div>
|
|
472
|
+
</div>
|
|
473
|
+
</Show>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
{/* footer */}
|
|
477
|
+
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-700 sticky bottom-0 z-10 bg-gray-900">
|
|
478
|
+
<button
|
|
479
|
+
onClick={handleCancel}
|
|
480
|
+
disabled={isLoading() || isDownloading()}
|
|
481
|
+
class="px-4 py-2 text-gray-400 hover:text-white disabled:text-gray-600 font-medium transition-colors"
|
|
482
|
+
>
|
|
483
|
+
close
|
|
484
|
+
</button>
|
|
485
|
+
<button
|
|
486
|
+
onClick={handleSave}
|
|
487
|
+
disabled={isLoading() || isDownloading()}
|
|
488
|
+
class="px-6 py-2 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white font-medium transition-colors flex items-center gap-2"
|
|
489
|
+
>
|
|
490
|
+
<Show
|
|
491
|
+
when={!isLoading()}
|
|
492
|
+
fallback={
|
|
493
|
+
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
494
|
+
}
|
|
495
|
+
>
|
|
496
|
+
<svg
|
|
497
|
+
class="w-4 h-4"
|
|
498
|
+
fill="none"
|
|
499
|
+
stroke="currentColor"
|
|
500
|
+
viewBox="0 0 24 24"
|
|
501
|
+
>
|
|
502
|
+
<path
|
|
503
|
+
stroke-linecap="round"
|
|
504
|
+
stroke-linejoin="round"
|
|
505
|
+
stroke-width="2"
|
|
506
|
+
d="M5 13l4 4L19 7"
|
|
507
|
+
/>
|
|
508
|
+
</svg>
|
|
509
|
+
</Show>
|
|
510
|
+
{isLoading() ? "saving..." : "save"}
|
|
511
|
+
</button>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
);
|
|
516
|
+
})()}
|
|
517
|
+
</Show>
|
|
518
|
+
);
|
|
519
|
+
}
|