@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,803 @@
1
+ import { createSignal, createEffect, Show, onMount } from "solid-js";
2
+ import {
3
+ updatePlaylist,
4
+ deletePlaylist,
5
+ setPlaylistCoverImage,
6
+ clearPlaylistCoverImage,
7
+ forkPlaylist,
8
+ } from "../services/playlistDocService.js";
9
+ import {
10
+ processPlaylistCover,
11
+ validateImageFile,
12
+ createImageUrlFromData,
13
+ } from "../services/imageService.js";
14
+ import { ensureSharingReady } from "../services/sharingService.js";
15
+ import { initSharingState } from "../services/sharingState.js";
16
+ import { usePlaylistzManager } from "../context/PlaylistzContext.js";
17
+ import type { Playlist, Song } from "../types/playlist.js";
18
+
19
+ interface PlaylistEditPanelProps {
20
+ playlist: Playlist;
21
+ playlistSongs: Song[];
22
+ onClose: () => void;
23
+ onSave: (updatedPlaylist: Playlist) => void;
24
+ onDelete?: () => void;
25
+ onFork?: (newDocId: string) => void;
26
+ }
27
+
28
+ export function PlaylistEditPanel(props: PlaylistEditPanelProps) {
29
+ const playlistManager = usePlaylistzManager();
30
+ const { backgroundSource, setBackgroundOverride } = playlistManager;
31
+ const { handleDownloadPlaylist, isDownloading } = playlistManager;
32
+
33
+ const [selectedImageUrl, setSelectedImageUrl] = createSignal<
34
+ string | undefined
35
+ >();
36
+ const [isLoading, setIsLoading] = createSignal(false);
37
+ const [error, setError] = createSignal<string | null>(null);
38
+ const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
39
+
40
+ onMount(() => {
41
+ initSharingState();
42
+ });
43
+
44
+ // update the displayed cover whenever the playlist's image resolves from
45
+ // the blob store. docToPlaylistAsync sets imageFilePath asynchronously after
46
+ // the initial sync, so onMount alone would miss it on a fresh page load.
47
+ createEffect(() => {
48
+ if (props.playlist.imageData && props.playlist.imageType) {
49
+ const displayData =
50
+ props.playlist.thumbnailData || props.playlist.imageData;
51
+ setSelectedImageUrl(
52
+ createImageUrlFromData(displayData, props.playlist.imageType)
53
+ );
54
+ } else if (props.playlist.imageFilePath) {
55
+ setSelectedImageUrl(props.playlist.imageFilePath);
56
+ }
57
+ });
58
+
59
+ const handleImageUpload = async (event: Event) => {
60
+ const input = event.target as HTMLInputElement;
61
+ const file = input.files?.[0];
62
+ if (!file) return;
63
+
64
+ const validation = validateImageFile(file);
65
+ if (!validation.valid) {
66
+ setError(validation.error || "invalid image file");
67
+ return;
68
+ }
69
+
70
+ try {
71
+ setIsLoading(true);
72
+ setError(null);
73
+
74
+ const result = await processPlaylistCover(file);
75
+
76
+ if (result.success && result.thumbnailData && result.imageData) {
77
+ const prevUrl = selectedImageUrl();
78
+ if (prevUrl) URL.revokeObjectURL(prevUrl);
79
+
80
+ setSelectedImageUrl(
81
+ createImageUrlFromData(result.thumbnailData, file.type)
82
+ );
83
+
84
+ // immediately persist - no save button needed
85
+ const updates = {
86
+ imageData: result.imageData,
87
+ thumbnailData: result.thumbnailData,
88
+ imageType: file.type,
89
+ updatedAt: Date.now(),
90
+ };
91
+ await setPlaylistCoverImage(
92
+ props.playlist.id,
93
+ result.imageData,
94
+ file.type
95
+ );
96
+ const { image: _image, ...rest } = props.playlist as Playlist & {
97
+ image?: unknown;
98
+ };
99
+ props.onSave({ ...rest, ...updates });
100
+ } else {
101
+ setError(result.error || "failed to process image");
102
+ }
103
+ } catch (err) {
104
+ setError("error uploading image");
105
+ console.error("image upload error:", err);
106
+ } finally {
107
+ setIsLoading(false);
108
+ }
109
+ };
110
+
111
+ const handleRemoveImage = async () => {
112
+ const url = selectedImageUrl();
113
+ if (url) URL.revokeObjectURL(url);
114
+ setSelectedImageUrl(undefined);
115
+
116
+ try {
117
+ setIsLoading(true);
118
+ setError(null);
119
+ const updates = {
120
+ imageData: undefined,
121
+ thumbnailData: undefined,
122
+ imageType: undefined,
123
+ updatedAt: Date.now(),
124
+ };
125
+ await clearPlaylistCoverImage(props.playlist.id);
126
+ const { image: _image, ...rest } = props.playlist as Playlist & {
127
+ image?: unknown;
128
+ };
129
+ props.onSave({ ...rest, ...updates });
130
+ } catch (err) {
131
+ setError("failed to remove image");
132
+ } finally {
133
+ setIsLoading(false);
134
+ }
135
+ };
136
+
137
+ const handleDeletePlaylist = async () => {
138
+ try {
139
+ setIsLoading(true);
140
+ setError(null);
141
+ await deletePlaylist(props.playlist.id);
142
+ setShowDeleteConfirm(false);
143
+ props.onDelete?.();
144
+ props.onClose();
145
+ } catch (err) {
146
+ setError("failed to delete playlist");
147
+ console.error("delete error:", err);
148
+ } finally {
149
+ setIsLoading(false);
150
+ }
151
+ };
152
+
153
+ // fork/collaborate state (only relevant for subscribed remote playlists)
154
+ const [isForkingOrCollab, setIsForkingOrCollab] = createSignal(false);
155
+ const [forkCollabStatus, setForkCollabStatus] = createSignal<string | null>(
156
+ null
157
+ );
158
+ const [collabMessage, setCollabMessage] = createSignal("");
159
+
160
+ const isSubscribed = () =>
161
+ !!props.playlist.remoteNodeId && !props.playlist.isForked;
162
+
163
+ const handleFork = async () => {
164
+ try {
165
+ setIsForkingOrCollab(true);
166
+ setForkCollabStatus(null);
167
+ const forked = await forkPlaylist(props.playlist.id);
168
+ props.onFork?.(forked.id);
169
+ props.onClose();
170
+ } catch (err) {
171
+ setForkCollabStatus("fork failed");
172
+ console.error("fork error:", err);
173
+ } finally {
174
+ setIsForkingOrCollab(false);
175
+ }
176
+ };
177
+
178
+ const handleRequestCollaboration = async () => {
179
+ const nodeId = props.playlist.remoteNodeId;
180
+ if (!nodeId) return;
181
+ try {
182
+ setIsForkingOrCollab(true);
183
+ setForkCollabStatus(null);
184
+ await ensureSharingReady();
185
+ const { knockForDocAccess } = await import(
186
+ "../services/sharingService.js"
187
+ );
188
+ const result = await knockForDocAccess(
189
+ nodeId,
190
+ props.playlist.id,
191
+ collabMessage() ||
192
+ "requesting collaboration access to: " +
193
+ (props.playlist.title || "playlist"),
194
+ props.playlist.title
195
+ );
196
+ if (result.status === "accepted") {
197
+ setForkCollabStatus("access granted - you can now collaborate");
198
+ } else if (result.status === "pending") {
199
+ setForkCollabStatus("knock sent - waiting for owner to accept");
200
+ } else {
201
+ setForkCollabStatus("request denied by owner");
202
+ }
203
+ } catch (err) {
204
+ setForkCollabStatus("knock failed");
205
+ console.error("collab knock error:", err);
206
+ } finally {
207
+ setIsForkingOrCollab(false);
208
+ }
209
+ };
210
+
211
+ const songsWithArt = () =>
212
+ props.playlistSongs.filter((s) => s.imageType || s.imageFilePath);
213
+
214
+ // filter settings - initialise from playlist props
215
+ const [bgEnabled, setBgEnabled] = createSignal(
216
+ props.playlist.bgFilterEnabled ?? true
217
+ );
218
+ const [bgBlur, setBgBlur] = createSignal(props.playlist.bgFilterBlur ?? 3);
219
+ const [bgContrast, setBgContrast] = createSignal(
220
+ props.playlist.bgFilterContrast ?? 3
221
+ );
222
+ const [bgBrightness, setBgBrightness] = createSignal(
223
+ props.playlist.bgFilterBrightness ?? 0.4
224
+ );
225
+ const [coverEnabled, setCoverEnabled] = createSignal(
226
+ props.playlist.coverFilterEnabled ?? true
227
+ );
228
+ const [coverBlur, setCoverBlur] = createSignal(
229
+ props.playlist.coverFilterBlur ?? 3
230
+ );
231
+
232
+ // background image layout settings
233
+ const [bgSize, setBgSize] = createSignal(props.playlist.bgSize ?? "cover");
234
+ const [bgPosition, setBgPosition] = createSignal(
235
+ props.playlist.bgPosition ?? "top"
236
+ );
237
+ const [bgRepeat, setBgRepeat] = createSignal(
238
+ props.playlist.bgRepeat ?? "no-repeat"
239
+ );
240
+
241
+ const BG_SIZE_OPTIONS = [
242
+ "cover",
243
+ "contain",
244
+ "auto",
245
+ "100% 100%",
246
+ "50%",
247
+ ] as const;
248
+ const BG_POSITION_OPTIONS = [
249
+ "top",
250
+ "center",
251
+ "bottom",
252
+ "left",
253
+ "right",
254
+ "left top",
255
+ "right top",
256
+ ] as const;
257
+ const BG_REPEAT_OPTIONS = [
258
+ "no-repeat",
259
+ "repeat",
260
+ "repeat-x",
261
+ "repeat-y",
262
+ ] as const;
263
+
264
+ const saveFilterUpdates = async (updates: Partial<typeof props.playlist>) => {
265
+ try {
266
+ await updatePlaylist(props.playlist.id, {
267
+ bgFilterEnabled: updates.bgFilterEnabled,
268
+ bgFilterBlur: updates.bgFilterBlur,
269
+ bgFilterContrast: updates.bgFilterContrast,
270
+ bgFilterBrightness: updates.bgFilterBrightness,
271
+ coverFilterEnabled: updates.coverFilterEnabled,
272
+ coverFilterBlur: updates.coverFilterBlur,
273
+ bgSize: updates.bgSize,
274
+ bgPosition: updates.bgPosition,
275
+ bgRepeat: updates.bgRepeat,
276
+ });
277
+ } catch (err) {
278
+ setError("failed to save filter settings");
279
+ console.error("filter save error:", err);
280
+ }
281
+ };
282
+
283
+ // updates the live preview immediately (no IDB write)
284
+ const previewFilter = (updates: Partial<typeof props.playlist>) => {
285
+ const { image: _image, ...rest } =
286
+ props.playlist as typeof props.playlist & { image?: unknown };
287
+ props.onSave({ ...rest, ...updates, updatedAt: Date.now() });
288
+ };
289
+
290
+ const resetBgFilter = () => {
291
+ setBgEnabled(true);
292
+ setBgBlur(3);
293
+ setBgContrast(3);
294
+ setBgBrightness(0.4);
295
+ const defaults = {
296
+ bgFilterEnabled: true,
297
+ bgFilterBlur: 3,
298
+ bgFilterContrast: 3,
299
+ bgFilterBrightness: 0.4,
300
+ };
301
+ previewFilter(defaults);
302
+ saveFilterUpdates(defaults);
303
+ };
304
+
305
+ const resetCoverFilter = () => {
306
+ setCoverEnabled(true);
307
+ setCoverBlur(3);
308
+ const defaults = { coverFilterEnabled: true, coverFilterBlur: 3 };
309
+ previewFilter(defaults);
310
+ saveFilterUpdates(defaults);
311
+ };
312
+
313
+ return (
314
+ <div data-testid="edit-panel" class="bg-black/40 overflow-hidden min-w-0">
315
+ {/* on sm+: form controls left (clamped to 500px), image right.
316
+ justify-between pushes the columns apart so spare width sits in the
317
+ middle. order utilities keep the image on top for the mobile column.
318
+ on lg+ a third share column appears on the right; below lg the share
319
+ section spans the full width under the other two columns */}
320
+ <div class="p-4 border-none grid grid-cols-1 min-w-0 sm:grid-cols-[minmax(0,500px)_min(40%,24rem)] sm:justify-between gap-6">
321
+ {/* image + upload buttons */}
322
+ <div class="flex flex-col gap-3 min-w-0 sm:order-2">
323
+ {/* image sizes naturally at its own aspect ratio (no gray bars);
324
+ the gray square only shows as the no-image fallback */}
325
+ <Show
326
+ when={selectedImageUrl()}
327
+ fallback={
328
+ <div class="w-full aspect-square bg-gray-700 flex items-center justify-center">
329
+ <svg
330
+ class="w-8 h-8 text-gray-400"
331
+ fill="none"
332
+ stroke="currentColor"
333
+ viewBox="0 0 24 24"
334
+ >
335
+ <path
336
+ stroke-linecap="round"
337
+ stroke-linejoin="round"
338
+ stroke-width="2"
339
+ 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"
340
+ />
341
+ </svg>
342
+ </div>
343
+ }
344
+ >
345
+ {/* hovering reveals a bottom strip; clicking anywhere on the image
346
+ sets the page background to this cover (only when the background
347
+ is currently something else, e.g. the song being edited) */}
348
+ <Show
349
+ when={
350
+ props.playlist.imageType &&
351
+ backgroundSource() !== `playlist-${props.playlist.id}`
352
+ }
353
+ fallback={
354
+ <img
355
+ src={selectedImageUrl()}
356
+ alt="playlist cover"
357
+ class="w-full h-auto"
358
+ />
359
+ }
360
+ >
361
+ <button
362
+ data-testid="btn-set-bg-cover"
363
+ onClick={() => setBackgroundOverride("cover")}
364
+ class="relative block w-full group cursor-pointer"
365
+ title="set the page background to this playlist's cover image"
366
+ >
367
+ <img
368
+ src={selectedImageUrl()}
369
+ alt="playlist cover"
370
+ class="w-full h-auto"
371
+ />
372
+ <span class="absolute bottom-0 inset-x-0 px-3 py-2 bg-black/70 text-white text-sm font-medium text-center opacity-0 group-hover:opacity-100 transition-opacity">
373
+ show cover background preview
374
+ </span>
375
+ </button>
376
+ </Show>
377
+ </Show>
378
+
379
+ <input
380
+ type="file"
381
+ accept="image/*"
382
+ onChange={handleImageUpload}
383
+ disabled={isLoading()}
384
+ class="hidden"
385
+ id="cover-upload-panel"
386
+ />
387
+ {/* mt-auto pushes the buttons to the column bottom so both columns
388
+ end at the same height regardless of image aspect ratio */}
389
+ <label
390
+ for="cover-upload-panel"
391
+ class="mt-auto block w-full px-3 py-1.5 bg-magenta-500 hover:bg-magenta-600 text-white cursor-pointer text-sm font-medium transition-colors text-center"
392
+ >
393
+ upload image
394
+ </label>
395
+ <Show when={selectedImageUrl()}>
396
+ <button
397
+ onClick={handleRemoveImage}
398
+ disabled={isLoading()}
399
+ class="block w-full px-3 py-1.5 bg-red-700 hover:bg-red-800 text-white text-sm font-medium transition-colors text-center"
400
+ >
401
+ remove cover image
402
+ </button>
403
+ </Show>
404
+ </div>
405
+
406
+ {/* filter controls + playlist info */}
407
+ <div class="flex flex-col gap-5 min-w-0 sm:order-1">
408
+ {/* subscribed playlist: fork or request collaboration */}
409
+ <Show when={isSubscribed()}>
410
+ <div class="space-y-2 border border-gray-700 p-3">
411
+ <p class="text-xs text-gray-400">
412
+ this is a subscribed playlist from{" "}
413
+ <span class="text-gray-200">
414
+ {props.playlist.remoteName ||
415
+ props.playlist.remoteNodeId?.slice(0, 8) + "..."}
416
+ </span>
417
+ . you can make your own editable copy or request edit access
418
+ from the owner.
419
+ </p>
420
+ <div class="flex gap-2">
421
+ <button
422
+ data-testid="btn-fork-playlist"
423
+ onClick={() => void handleFork()}
424
+ disabled={isForkingOrCollab()}
425
+ class="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm transition-colors"
426
+ >
427
+ make my own copy
428
+ </button>
429
+ <button
430
+ data-testid="btn-request-collaboration"
431
+ onClick={() => void handleRequestCollaboration()}
432
+ disabled={isForkingOrCollab()}
433
+ class="flex-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm transition-colors"
434
+ >
435
+ request collaboration
436
+ </button>
437
+ </div>
438
+ <textarea
439
+ data-testid="input-collab-message"
440
+ value={collabMessage()}
441
+ onInput={(e) => setCollabMessage(e.currentTarget.value)}
442
+ placeholder="optional message to the owner..."
443
+ rows="2"
444
+ disabled={isForkingOrCollab()}
445
+ class="w-full bg-black text-white text-xs border border-gray-700 px-2 py-1.5 focus:outline-none focus:border-magenta-500 resize-none disabled:opacity-50"
446
+ />
447
+ <Show when={forkCollabStatus()}>
448
+ <p class="text-xs text-gray-400">{forkCollabStatus()}</p>
449
+ </Show>
450
+ </div>
451
+ </Show>
452
+
453
+ {/* background image filter */}
454
+ <div class="space-y-3">
455
+ <div class="flex items-center gap-2">
456
+ <label class="text-sm font-medium text-gray-300">
457
+ background filter
458
+ </label>
459
+ <input
460
+ type="checkbox"
461
+ checked={bgEnabled()}
462
+ onChange={(e) => {
463
+ const v = e.currentTarget.checked;
464
+ setBgEnabled(v);
465
+ previewFilter({ bgFilterEnabled: v });
466
+ saveFilterUpdates({ bgFilterEnabled: v });
467
+ }}
468
+ class="accent-magenta-500"
469
+ />
470
+ <button
471
+ onClick={resetBgFilter}
472
+ class="ml-auto px-2 py-0.5 text-xs text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 transition-colors"
473
+ >
474
+ reset
475
+ </button>
476
+ </div>
477
+ <div
478
+ class={`space-y-2 ${bgEnabled() ? "" : "opacity-40 pointer-events-none"}`}
479
+ >
480
+ <div class="grid grid-cols-[5rem_1fr_3rem] items-center gap-2">
481
+ <label class="text-xs text-gray-400">blur</label>
482
+ <input
483
+ type="range"
484
+ min="0"
485
+ max="20"
486
+ step="0.5"
487
+ value={bgBlur()}
488
+ onInput={(e) => {
489
+ const v = Number(e.currentTarget.value);
490
+ setBgBlur(v);
491
+ previewFilter({
492
+ bgFilterBlur: v,
493
+ bgFilterEnabled: bgEnabled(),
494
+ bgFilterContrast: bgContrast(),
495
+ bgFilterBrightness: bgBrightness(),
496
+ });
497
+ }}
498
+ onChange={(e) =>
499
+ saveFilterUpdates({
500
+ bgFilterBlur: Number(e.currentTarget.value),
501
+ })
502
+ }
503
+ class="accent-magenta-500"
504
+ />
505
+ <span class="text-xs text-gray-400 tabular-nums">
506
+ {bgBlur()}px
507
+ </span>
508
+ </div>
509
+ <div class="grid grid-cols-[5rem_1fr_3rem] items-center gap-2">
510
+ <label class="text-xs text-gray-400">contrast</label>
511
+ <input
512
+ type="range"
513
+ min="0"
514
+ max="10"
515
+ step="0.1"
516
+ value={bgContrast()}
517
+ onInput={(e) => {
518
+ const v = Number(e.currentTarget.value);
519
+ setBgContrast(v);
520
+ previewFilter({
521
+ bgFilterContrast: v,
522
+ bgFilterEnabled: bgEnabled(),
523
+ bgFilterBlur: bgBlur(),
524
+ bgFilterBrightness: bgBrightness(),
525
+ });
526
+ }}
527
+ onChange={(e) =>
528
+ saveFilterUpdates({
529
+ bgFilterContrast: Number(e.currentTarget.value),
530
+ })
531
+ }
532
+ class="accent-magenta-500"
533
+ />
534
+ <span class="text-xs text-gray-400 tabular-nums">
535
+ {bgContrast().toFixed(1)}
536
+ </span>
537
+ </div>
538
+ <div class="grid grid-cols-[5rem_1fr_3rem] items-center gap-2">
539
+ <label class="text-xs text-gray-400">brightness</label>
540
+ <input
541
+ type="range"
542
+ min="0"
543
+ max="2"
544
+ step="0.05"
545
+ value={bgBrightness()}
546
+ onInput={(e) => {
547
+ const v = Number(e.currentTarget.value);
548
+ setBgBrightness(v);
549
+ previewFilter({
550
+ bgFilterBrightness: v,
551
+ bgFilterEnabled: bgEnabled(),
552
+ bgFilterBlur: bgBlur(),
553
+ bgFilterContrast: bgContrast(),
554
+ });
555
+ }}
556
+ onChange={(e) =>
557
+ saveFilterUpdates({
558
+ bgFilterBrightness: Number(e.currentTarget.value),
559
+ })
560
+ }
561
+ class="accent-magenta-500"
562
+ />
563
+ <span class="text-xs text-gray-400 tabular-nums">
564
+ {bgBrightness().toFixed(2)}
565
+ </span>
566
+ </div>
567
+ </div>
568
+ </div>
569
+
570
+ {/* cover image filter */}
571
+ <div class="space-y-3">
572
+ <div class="flex items-center gap-2">
573
+ <label class="text-sm font-medium text-gray-300">
574
+ cover blur
575
+ </label>
576
+ <input
577
+ type="checkbox"
578
+ checked={coverEnabled()}
579
+ onChange={(e) => {
580
+ const v = e.currentTarget.checked;
581
+ setCoverEnabled(v);
582
+ previewFilter({ coverFilterEnabled: v });
583
+ saveFilterUpdates({ coverFilterEnabled: v });
584
+ }}
585
+ class="accent-magenta-500"
586
+ />
587
+ <button
588
+ onClick={resetCoverFilter}
589
+ class="ml-auto px-2 py-0.5 text-xs text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 transition-colors"
590
+ >
591
+ reset
592
+ </button>
593
+ </div>
594
+ <div
595
+ class={`grid grid-cols-[5rem_1fr_3rem] items-center gap-2 ${coverEnabled() ? "" : "opacity-40 pointer-events-none"}`}
596
+ >
597
+ <label class="text-xs text-gray-400">blur</label>
598
+ <input
599
+ type="range"
600
+ min="0"
601
+ max="20"
602
+ step="0.5"
603
+ value={coverBlur()}
604
+ onInput={(e) => {
605
+ const v = Number(e.currentTarget.value);
606
+ setCoverBlur(v);
607
+ previewFilter({
608
+ coverFilterBlur: v,
609
+ coverFilterEnabled: coverEnabled(),
610
+ });
611
+ }}
612
+ onChange={(e) =>
613
+ saveFilterUpdates({
614
+ coverFilterBlur: Number(e.currentTarget.value),
615
+ })
616
+ }
617
+ class="accent-magenta-500"
618
+ />
619
+ <span class="text-xs text-gray-400 tabular-nums">
620
+ {coverBlur()}px
621
+ </span>
622
+ </div>
623
+ </div>
624
+
625
+ {/* background image layout */}
626
+ <div class="space-y-3">
627
+ <div class="flex items-center gap-2">
628
+ <label class="text-sm font-medium text-gray-300">
629
+ background image
630
+ </label>
631
+ <button
632
+ onClick={() => {
633
+ setBgSize("cover");
634
+ setBgPosition("top");
635
+ setBgRepeat("no-repeat");
636
+ const defaults = {
637
+ bgSize: "cover",
638
+ bgPosition: "top",
639
+ bgRepeat: "no-repeat",
640
+ };
641
+ previewFilter(defaults);
642
+ void saveFilterUpdates(defaults);
643
+ }}
644
+ class="ml-auto px-2 py-0.5 text-xs text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 transition-colors"
645
+ >
646
+ reset
647
+ </button>
648
+ </div>
649
+ <div class="grid grid-cols-[5rem_1fr] items-center gap-2">
650
+ <label class="text-xs text-gray-400">size</label>
651
+ <select
652
+ value={bgSize()}
653
+ onChange={(e) => {
654
+ const v = e.currentTarget.value;
655
+ setBgSize(v);
656
+ previewFilter({ bgSize: v });
657
+ void saveFilterUpdates({ bgSize: v });
658
+ }}
659
+ class="bg-black text-white text-xs border border-gray-700 px-2 py-1 focus:outline-none focus:border-magenta-500"
660
+ >
661
+ {BG_SIZE_OPTIONS.map((o) => (
662
+ <option value={o}>{o}</option>
663
+ ))}
664
+ </select>
665
+ </div>
666
+ <div class="grid grid-cols-[5rem_1fr] items-center gap-2">
667
+ <label class="text-xs text-gray-400">position</label>
668
+ <select
669
+ value={bgPosition()}
670
+ onChange={(e) => {
671
+ const v = e.currentTarget.value;
672
+ setBgPosition(v);
673
+ previewFilter({ bgPosition: v });
674
+ void saveFilterUpdates({ bgPosition: v });
675
+ }}
676
+ class="bg-black text-white text-xs border border-gray-700 px-2 py-1 focus:outline-none focus:border-magenta-500"
677
+ >
678
+ {BG_POSITION_OPTIONS.map((o) => (
679
+ <option value={o}>{o}</option>
680
+ ))}
681
+ </select>
682
+ </div>
683
+ <div class="grid grid-cols-[5rem_1fr] items-center gap-2">
684
+ <label class="text-xs text-gray-400">repeat</label>
685
+ <select
686
+ value={bgRepeat()}
687
+ onChange={(e) => {
688
+ const v = e.currentTarget.value;
689
+ setBgRepeat(v);
690
+ previewFilter({ bgRepeat: v });
691
+ void saveFilterUpdates({ bgRepeat: v });
692
+ }}
693
+ class="bg-black text-white text-xs border border-gray-700 px-2 py-1 focus:outline-none focus:border-magenta-500"
694
+ >
695
+ {BG_REPEAT_OPTIONS.map((o) => (
696
+ <option value={o}>{o}</option>
697
+ ))}
698
+ </select>
699
+ </div>
700
+ </div>
701
+
702
+ {/* playlist info - mt-auto pushes this (and everything after) to the
703
+ bottom so the column stretches to match the image column height */}
704
+ <div class="bg-black p-3 text-xs text-gray-400 space-y-1 mt-auto">
705
+ <div>title: {props.playlist.title}</div>
706
+ <div>id: {props.playlist.id}</div>
707
+ <div>rev: {props.playlist.rev || 0}</div>
708
+ <div>songz: {props.playlist.songIds.length}</div>
709
+ <div>with album art: {songsWithArt().length}</div>
710
+ </div>
711
+
712
+ {/* actions: download + delete */}
713
+ <div class="flex flex-col gap-2">
714
+ <Show when={window.location.protocol !== "file:"}>
715
+ <button
716
+ data-testid="btn-download-zip"
717
+ onClick={handleDownloadPlaylist}
718
+ disabled={isDownloading() || isLoading()}
719
+ class="w-full px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-400 text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
720
+ >
721
+ <Show
722
+ when={!isDownloading()}
723
+ fallback={
724
+ <div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
725
+ }
726
+ >
727
+ <svg
728
+ class="w-4 h-4"
729
+ fill="none"
730
+ stroke="currentColor"
731
+ viewBox="0 0 24 24"
732
+ >
733
+ <path
734
+ stroke-linecap="round"
735
+ stroke-linejoin="round"
736
+ stroke-width="2"
737
+ 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"
738
+ />
739
+ </svg>
740
+ </Show>
741
+ {isDownloading() ? "downloading..." : "download playlist"}
742
+ </button>
743
+ </Show>
744
+
745
+ <Show
746
+ when={!showDeleteConfirm()}
747
+ fallback={
748
+ <div class="bg-red-900/30 border border-red-500 p-2 space-y-2">
749
+ <p class="text-white text-sm">delete this playlist?</p>
750
+ <div class="flex gap-2">
751
+ <button
752
+ onClick={handleDeletePlaylist}
753
+ disabled={isLoading()}
754
+ class="flex-1 px-3 py-1.5 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium transition-colors"
755
+ >
756
+ yes, delete
757
+ </button>
758
+ <button
759
+ onClick={() => setShowDeleteConfirm(false)}
760
+ disabled={isLoading()}
761
+ class="flex-1 px-3 py-1.5 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium transition-colors"
762
+ >
763
+ cancel
764
+ </button>
765
+ </div>
766
+ </div>
767
+ }
768
+ >
769
+ <button
770
+ onClick={() => setShowDeleteConfirm(true)}
771
+ disabled={isLoading()}
772
+ class="w-full px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
773
+ >
774
+ <svg
775
+ class="w-4 h-4"
776
+ fill="none"
777
+ stroke="currentColor"
778
+ viewBox="0 0 24 24"
779
+ >
780
+ <path
781
+ stroke-linecap="round"
782
+ stroke-linejoin="round"
783
+ stroke-width="2"
784
+ 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"
785
+ />
786
+ </svg>
787
+ delete playlist
788
+ </button>
789
+ </Show>
790
+ </div>
791
+
792
+ <Show when={error()}>
793
+ <div class="bg-red-900/30 border border-red-500 p-3">
794
+ <div class="text-red-400 text-sm">{error()}</div>
795
+ </div>
796
+ </Show>
797
+ </div>
798
+
799
+ {/* p2p share column removed - use the share panel (sidebar) instead */}
800
+ </div>
801
+ </div>
802
+ );
803
+ }