@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,1203 @@
1
+ import {
2
+ Accessor,
3
+ Show,
4
+ For,
5
+ createSignal,
6
+ createEffect,
7
+ createMemo,
8
+ on,
9
+ onCleanup,
10
+ onMount,
11
+ } from "solid-js";
12
+ import type { Playlist, Song } from "../../types/playlist.js";
13
+ import {
14
+ usePlaylistzManager,
15
+ usePlaylistzSongs,
16
+ usePlaylistzUI,
17
+ usePlaylistzImageModal,
18
+ usePlaylistzDragDrop,
19
+ } from "../../context/PlaylistzContext.js";
20
+ import { getImageUrlForContext } from "../../services/imageService.js";
21
+ import { audioState } from "../../services/audioService.js";
22
+ import {
23
+ initSharingState,
24
+ sharingReady,
25
+ pendingKnockCount,
26
+ outboundPendingCount,
27
+ connectedPeerCount,
28
+ isTransferring,
29
+ } from "../../services/sharingState.js";
30
+ import {
31
+ savePlaylistOffline,
32
+ playlistHasMissingBlobs,
33
+ type OfflineProgress,
34
+ } from "../../services/blobTransferService.js";
35
+ import { AudioPlayer } from "../AudioPlayer.js";
36
+ import { SongRow } from "../SongRow.js";
37
+ import { PlaylistEditPanel } from "../PlaylistEditPanel.js";
38
+ import { SongEditPanel } from "../SongEditPanel.js";
39
+ import { PlaylistSharePanel } from "../PlaylistSharePanel.js";
40
+ import { AllPlaylistsPanel } from "../AllPlaylistsPanel.js";
41
+ import { forkPlaylist } from "../../services/playlistDocService.js";
42
+
43
+ import { log } from "../../utils/log.js";
44
+
45
+ export function PlaylistContainer(props: { playlist: Accessor<Playlist> }) {
46
+ const playlistManager = usePlaylistzManager();
47
+ const songState = usePlaylistzSongs();
48
+ const uiState = usePlaylistzUI();
49
+ const imageModal = usePlaylistzImageModal();
50
+ const dragDrop = usePlaylistzDragDrop();
51
+
52
+ onMount(() => initSharingState());
53
+
54
+ const {
55
+ playlists,
56
+ playlistSongs,
57
+ isDownloading,
58
+ isCaching,
59
+ allSongsCached,
60
+ handlePlaylistUpdate,
61
+ handleDownloadPlaylist,
62
+ handleCachePlaylist,
63
+ handleRemoveSong,
64
+ handleReorderSongs,
65
+ setBackgroundOverride,
66
+ } = playlistManager;
67
+
68
+ // read-only mode: playlist is subscribed from a remote peer and not yet forked
69
+ const isSubscribed = () =>
70
+ !!props.playlist().remoteNodeId && !props.playlist().isForked;
71
+
72
+ const {
73
+ handleEditSong,
74
+ handleEditPlaylist,
75
+ handlePlaySong,
76
+ handlePauseSong,
77
+ editingSong,
78
+ editingPlaylist,
79
+ setEditingSong,
80
+ handleCloseEdit,
81
+ handleSongSaved,
82
+ } = songState;
83
+
84
+ // create a wrapper that passes the playlist context
85
+ const handlePlaySongWithPlaylist = async (song: Song) => {
86
+ await handlePlaySong(song, props.playlist());
87
+ };
88
+
89
+ // p2p save offline: fetch all missing blobs from the doc's peers
90
+ const [p2pSaveProgress, setP2pSaveProgress] =
91
+ createSignal<OfflineProgress | null>(null);
92
+ // hide the save-offline button once every referenced blob is local
93
+ const [p2pHasMissing, setP2pHasMissing] = createSignal(false);
94
+ createEffect(
95
+ on(
96
+ // re-check whenever the song list changes OR after a save-offline run
97
+ // completes (p2pSaveProgress transitions back to null)
98
+ () =>
99
+ [
100
+ props.playlist().id,
101
+ playlistSongs().length,
102
+ p2pSaveProgress() === null,
103
+ ] as const,
104
+ () => {
105
+ void playlistHasMissingBlobs(props.playlist())
106
+ .then(setP2pHasMissing)
107
+ .catch(() => setP2pHasMissing(false));
108
+ }
109
+ )
110
+ );
111
+ const handleP2pSaveOffline = async () => {
112
+ if (p2pSaveProgress()) return;
113
+ setP2pSaveProgress({ done: 0, total: 0, currentTitle: "", fraction: 0 });
114
+ try {
115
+ await savePlaylistOffline(props.playlist(), (p) => setP2pSaveProgress(p));
116
+ setP2pHasMissing(
117
+ await playlistHasMissingBlobs(props.playlist()).catch(() => false)
118
+ );
119
+ } catch (err) {
120
+ log.warn("p2p.save", "p2p save offline failed:", err);
121
+ } finally {
122
+ setP2pSaveProgress(null);
123
+ }
124
+ };
125
+
126
+ const { isMobile } = uiState;
127
+
128
+ const { openImageModal } = imageModal;
129
+
130
+ // share panel state - declared before isEditing so the memo can reference it
131
+ const [showingShare, setShowingShare] = createSignal(false);
132
+ const [showAllPlaylists, setShowAllPlaylists] = createSignal(false);
133
+ // when set, AllPlaylistsPanel opens with this peer nodeId pre-searched
134
+ const [allPlaylistsPeerQuery, setAllPlaylistsPeerQuery] = createSignal<
135
+ string | undefined
136
+ >(undefined);
137
+
138
+ const closeShare = () => {
139
+ setShowingShare(false);
140
+ };
141
+
142
+ // true when any edit panel, share panel, or all-playlists view is open.
143
+ const isEditing = createMemo(
144
+ () =>
145
+ editingSong() !== null ||
146
+ editingPlaylist() ||
147
+ showingShare() ||
148
+ showAllPlaylists()
149
+ );
150
+
151
+ // index of the song being edited (for directional row animation)
152
+ const editingSongIndex = () => {
153
+ const song = editingSong();
154
+ if (!song) return -1;
155
+ return props.playlist().songIds.indexOf(song.id);
156
+ };
157
+
158
+ // neighbouring song relative to the one being edited (for panel navigation)
159
+ const songAtOffset = (offset: number): Song | undefined => {
160
+ const idx = editingSongIndex();
161
+ if (idx < 0) return undefined;
162
+ const targetId = props.playlist().songIds[idx + offset];
163
+ if (!targetId) return undefined;
164
+ return playlistSongs().find((s) => s.id === targetId);
165
+ };
166
+
167
+ const FLYOUT_MS = 100;
168
+
169
+ // stagger delay per row during exit (in ms)
170
+ const rowExitDelayMs = (index: number): number =>
171
+ index < 5 ? index * 20 : 50 + (index - 5) * 5;
172
+
173
+ // which CSS keyframe to use for a row's exit
174
+ const rowExitKeyframe = (rowIndex: number): string => {
175
+ if (editingPlaylist() || showAllPlaylists()) return "rowFlyDown";
176
+ const editIdx = editingSongIndex();
177
+ if (editIdx >= 0) {
178
+ return rowIndex < editIdx ? "rowFlyUp" : "rowFlyDown";
179
+ }
180
+ return "rowFlyDown";
181
+ };
182
+
183
+ // phase signal: tracks whether rows have completed their exit animation.
184
+ // "gone" = rows are done animating and should be collapsed out of layout.
185
+ const [rowsGone, setRowsGone] = createSignal(false);
186
+
187
+ // scroll container ref - reset scroll when an edit panel opens so the
188
+ // panel top is never cut off (e.g. when editing songs at the end of a long playlist)
189
+ let scrollContainerRef: HTMLDivElement | undefined;
190
+
191
+ createEffect(() => {
192
+ if (isEditing()) {
193
+ scrollContainerRef?.scrollTo({ top: 0 });
194
+ }
195
+ });
196
+
197
+ // in playlist edit mode, a song edit panel is shown below the playlist
198
+ // panel. default it to the first song, and re-sync when switching
199
+ // playlists (the previous playlist's song may not exist here)
200
+ createEffect(() => {
201
+ if (!editingPlaylist()) return;
202
+ const ids = props.playlist().songIds || [];
203
+ const current = editingSong();
204
+ if (current && ids.includes(current.id)) return;
205
+ const first = ids.length
206
+ ? playlistSongs().find((s) => s.id === ids[0])
207
+ : undefined;
208
+ setEditingSong(first ?? null);
209
+ });
210
+
211
+ // while in playlist edit mode, the page background follows the song being
212
+ // edited (if it has an image) so the filter sliders are easier to tune.
213
+ // the "use cover" button in the edit panel can override this until the
214
+ // editing song changes again. cleared when leaving edit mode
215
+ createEffect(
216
+ on([editingPlaylist, editingSong], ([inPlaylistEdit, song]) => {
217
+ if (inPlaylistEdit && song?.imageType) {
218
+ setBackgroundOverride(song);
219
+ } else {
220
+ setBackgroundOverride(null);
221
+ }
222
+ })
223
+ );
224
+
225
+ onCleanup(() => setBackgroundOverride(null));
226
+
227
+ // escape key closes the edit panels or share panel
228
+ onMount(() => {
229
+ const onKeyDown = (e: KeyboardEvent) => {
230
+ if (e.key === "Escape") {
231
+ if (showAllPlaylists()) {
232
+ setShowAllPlaylists(false);
233
+ return;
234
+ }
235
+ if (showingShare()) {
236
+ closeShare();
237
+ return;
238
+ }
239
+ if (isEditing()) handleCloseEdit();
240
+ }
241
+ };
242
+ document.addEventListener("keydown", onKeyDown);
243
+ onCleanup(() => document.removeEventListener("keydown", onKeyDown));
244
+ });
245
+
246
+ // only animate rows on the closed -> open transition. navigating between
247
+ // song edit panels keeps isEditing() true, so rowsGone stays true and the
248
+ // hidden rows don't flash back in between panels
249
+ createEffect(
250
+ on(isEditing, (editing, prevEditing) => {
251
+ log.debug(
252
+ "playlist.rows",
253
+ "rowsGone effect",
254
+ JSON.stringify({ editing, prevEditing })
255
+ );
256
+ if (editing && !prevEditing) {
257
+ setRowsGone(false);
258
+ // collapse layout and show panel after the first few rows have started
259
+ // exiting - remaining row animations complete behind the panel
260
+ const totalMs = rowExitDelayMs(2) + FLYOUT_MS;
261
+ const t = setTimeout(() => {
262
+ log.debug("playlist.rows", "rowsGone -> true");
263
+ setRowsGone(true);
264
+ }, totalMs);
265
+ onCleanup(() => clearTimeout(t));
266
+ } else if (!editing) {
267
+ setRowsGone(false);
268
+ }
269
+ })
270
+ );
271
+
272
+ // outer wrapper: collapses to 0 height ONLY after animation completes.
273
+ // no overflow:hidden here so inner transforms can fly freely.
274
+ const rowOuterStyle = () =>
275
+ rowsGone()
276
+ ? { "max-height": "0px", overflow: "hidden" as const }
277
+ : { "max-height": "400px" };
278
+
279
+ // inner wrapper: CSS keyframe animation.
280
+ // animation-name changes trigger a fresh animation on every edit mode transition.
281
+ const rowInnerStyle = (rowIndex: number) => {
282
+ if (isEditing() && !rowsGone()) {
283
+ const delay = rowExitDelayMs(rowIndex);
284
+ return {
285
+ animation: `${rowExitKeyframe(rowIndex)} ${FLYOUT_MS}ms ease ${delay}ms both`,
286
+ };
287
+ }
288
+ if (!isEditing()) {
289
+ // fly back in when returning from edit mode (all rows together, subtle)
290
+ return { animation: `rowFlyIn ${FLYOUT_MS}ms ease both` };
291
+ }
292
+ return {};
293
+ };
294
+
295
+ // header collapses out of layout only when editing a specific song (and not
296
+ // in playlist edit mode). stays visible for share, all-playlists, and
297
+ // playlist edit mode.
298
+ // overflow:hidden only applied while collapsing so it doesn't clip mobile content.
299
+ const headerStyle = () =>
300
+ editingSong() && !editingPlaylist()
301
+ ? {
302
+ transition: "max-height 350ms ease, opacity 300ms ease",
303
+ "max-height": "0px",
304
+ overflow: "hidden" as const,
305
+ opacity: "0",
306
+ "pointer-events": "none" as const,
307
+ }
308
+ : {
309
+ transition: "max-height 350ms ease, opacity 300ms ease",
310
+ "max-height": "1200px",
311
+ opacity: "1",
312
+ "pointer-events": "auto" as const,
313
+ };
314
+
315
+ // panel slides in immediately after rows have collapsed (panel only mounts when rowsGone())
316
+ const panelEntryStyle = () =>
317
+ ({ animation: "slideDown 150ms ease both" }) as const;
318
+
319
+ // height of the mobile sticky controls bar - active song rows stick just
320
+ // below it instead of hiding underneath
321
+ let stickyBarRef: HTMLDivElement | undefined;
322
+ const [stickyBarHeight, setStickyBarHeight] = createSignal(0);
323
+ createEffect(() => {
324
+ if (!isMobile()) {
325
+ setStickyBarHeight(0);
326
+ return;
327
+ }
328
+ const el = stickyBarRef;
329
+ if (!el) return;
330
+ setStickyBarHeight(el.offsetHeight);
331
+ const ro = new ResizeObserver(() => setStickyBarHeight(el.offsetHeight));
332
+ ro.observe(el);
333
+ onCleanup(() => ro.disconnect());
334
+ });
335
+
336
+ return (
337
+ <div
338
+ class={`flex-1 flex flex-col min-h-0 [overflow-x:clip] ${isMobile() ? "p-2" : "p-6"}`}
339
+ >
340
+ {(() => {
341
+ // playlist header - animates up/out only when editing a specific song
342
+ // (not in playlist edit mode). stays visible for share, all-playlists,
343
+ // and playlist edit mode. on mobile it renders inside the scroll
344
+ // container so the cover image + title scroll with content, while the
345
+ // player controls bar stays sticky at the top
346
+ const headerSection = () => (
347
+ <div
348
+ style={headerStyle()}
349
+ class={`flex items-center justify-between ${isMobile() ? "flex-col" : "p-6"}`}
350
+ >
351
+ {/* playlist cover image for mobile - hidden in edit mode (edit panel has its own) */}
352
+ <div class={`${isMobile() && !isEditing() ? "" : "hidden"}`}>
353
+ <button
354
+ onClick={() => {
355
+ openImageModal(props.playlist(), playlistSongs(), 0);
356
+ }}
357
+ class="w-full h-full overflow-hidden hover:bg-gray-900 flex items-center justify-center transition-colors group"
358
+ title="view playlist images"
359
+ >
360
+ <Show
361
+ when={props.playlist().imageType}
362
+ fallback={
363
+ <div class="text-center">
364
+ <svg
365
+ width="100"
366
+ height="100"
367
+ viewBox="0 0 100 100"
368
+ fill="none"
369
+ xmlns="http://www.w3.org/2000/svg"
370
+ >
371
+ <path
372
+ d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
373
+ fill="#FF00FF"
374
+ />
375
+ </svg>
376
+ </div>
377
+ }
378
+ >
379
+ {(() => {
380
+ const imageUrl = getImageUrlForContext(
381
+ props.playlist(),
382
+ "modal"
383
+ );
384
+ return (
385
+ <>
386
+ {imageUrl ? (
387
+ <img
388
+ src={imageUrl}
389
+ alt="playlist cover"
390
+ class="w-full h-full object-cover"
391
+ />
392
+ ) : (
393
+ <div class="text-center">
394
+ <svg
395
+ width="100"
396
+ height="100"
397
+ viewBox="0 0 100 100"
398
+ fill="none"
399
+ xmlns="http://www.w3.org/2000/svg"
400
+ >
401
+ <path
402
+ d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
403
+ fill="#FF00FF"
404
+ />
405
+ </svg>
406
+ </div>
407
+ )}
408
+ </>
409
+ );
410
+ })()}
411
+ </Show>
412
+ </button>
413
+ </div>
414
+
415
+ <div class="flex items-center gap-4 w-full">
416
+ <div class="flex-1">
417
+ <div class={`bg-black bg-opacity-80`}>
418
+ <input
419
+ data-testid="input-playlist-title"
420
+ type="text"
421
+ value={props.playlist().title}
422
+ onInput={(e) => {
423
+ handlePlaylistUpdate({
424
+ title: e.currentTarget.value,
425
+ });
426
+ }}
427
+ disabled={isSubscribed()}
428
+ class="text-3xl font-bold text-white bg-transparent border-none outline-none focus:bg-gray-800 px-2 py-1 rounded w-full disabled:opacity-60 disabled:cursor-not-allowed"
429
+ placeholder="playlist title"
430
+ />
431
+ </div>
432
+ <div class={`bg-black bg-opacity-80`}>
433
+ <input
434
+ data-testid="input-playlist-description"
435
+ type="text"
436
+ value={props.playlist().description || ""}
437
+ placeholder="add description..."
438
+ onInput={(e) => {
439
+ handlePlaylistUpdate({
440
+ description: e.currentTarget.value,
441
+ });
442
+ }}
443
+ disabled={isSubscribed()}
444
+ class="text-white bg-transparent border-none focus:bg-gray-800 px-2 py-1 rounded w-full disabled:opacity-60 disabled:cursor-not-allowed"
445
+ />
446
+ </div>
447
+
448
+ {/* read-only banner for subscribed playlists */}
449
+ <Show when={isSubscribed()}>
450
+ <SubscribedBanner
451
+ playlist={props.playlist()}
452
+ onFork={(newDocId) => {
453
+ playlistManager.selectById(newDocId);
454
+ }}
455
+ />
456
+ </Show>
457
+
458
+ {/* player + action buttons grid - inline here on desktop, a
459
+ sticky bar inside the scroll container on mobile */}
460
+ <Show when={!isMobile()}>{playerControls()}</Show>
461
+ </div>
462
+ </div>
463
+
464
+ {/* playlist cover image (desktop) */}
465
+ {coverImage()}
466
+ </div>
467
+ );
468
+
469
+ // hoisted function declarations so headerSection (above) and the
470
+ // mobile sticky bar (below) can both render these
471
+ function playerControls() {
472
+ // 2x2 grid layout with AudioPlayer spanning left side
473
+ return (
474
+ <div
475
+ class="grid gap-3"
476
+ style={{
477
+ "grid-template-columns": "auto 1fr",
478
+ "grid-template-areas": "'player info' 'player buttons'",
479
+ }}
480
+ >
481
+ {/* AudioPlayer spans 2 rows on the left */}
482
+ <div
483
+ class="flex items-center justify-center"
484
+ style={{ "grid-area": "player" }}
485
+ >
486
+ <AudioPlayer playlist={props.playlist()} size="w-12 h-12" />
487
+ </div>
488
+
489
+ {/* top right song info stuff */}
490
+ <div
491
+ id="song-info"
492
+ class="flex items-center justify-end text-sm gap-0"
493
+ style={{ "grid-area": "info" }}
494
+ >
495
+ {/* sharer identity pill - shown when playlist is subscribed from a remote peer */}
496
+ <Show when={props.playlist().remoteNodeId}>
497
+ <button
498
+ data-testid="btn-browse-sharer"
499
+ class="flex items-center gap-1 bg-black/80 px-1.5 py-2 text-xs text-gray-400 hover:text-magenta-300 hover:bg-black transition-colors"
500
+ title={`browse ${props.playlist().remoteName || props.playlist().remoteNodeId?.slice(0, 16)}'s playlistz`}
501
+ onClick={() => {
502
+ if (showingShare()) closeShare();
503
+ if (editingPlaylist() || editingSong()) handleCloseEdit();
504
+ setAllPlaylistsPeerQuery(props.playlist().remoteNodeId);
505
+ setShowAllPlaylists(true);
506
+ }}
507
+ >
508
+ <Show
509
+ when={props.playlist().remoteAvatarDataUrl}
510
+ fallback={
511
+ <span class="inline-flex items-center justify-center w-4 h-4 bg-magenta-700/60 text-white text-[9px] font-bold shrink-0 overflow-hidden rounded-full">
512
+ {(
513
+ props.playlist().remoteName ||
514
+ props.playlist().remoteNodeId ||
515
+ ""
516
+ )
517
+ .slice(0, 1)
518
+ .toUpperCase()}
519
+ </span>
520
+ }
521
+ >
522
+ <img
523
+ src={props.playlist().remoteAvatarDataUrl}
524
+ alt={props.playlist().remoteName || "peer"}
525
+ class="w-4 h-4 rounded-full object-cover shrink-0"
526
+ />
527
+ </Show>
528
+ <span class="truncate max-w-[6rem]">
529
+ {props.playlist().remoteName ||
530
+ props.playlist().remoteNodeId?.slice(0, 8)}
531
+ </span>
532
+ </button>
533
+ </Show>
534
+ <span
535
+ data-testid="playlist-song-count"
536
+ class="bg-black bg-opacity-80 p-2"
537
+ >
538
+ {props.playlist().songIds?.length || 0} song
539
+ {(props.playlist().songIds?.length || 0) !== 1 ? "z" : ""}
540
+ </span>
541
+ <span
542
+ data-testid="playlist-total-time"
543
+ class="bg-black bg-opacity-80 p-2"
544
+ >
545
+ {(() => {
546
+ const totalSeconds = playlistSongs().reduce(
547
+ (total, song) => total + (song.duration || 0),
548
+ 0
549
+ );
550
+ const hours = Math.floor(totalSeconds / 3600);
551
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
552
+ const seconds = Math.floor(totalSeconds % 60);
553
+ return hours > 0
554
+ ? `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`
555
+ : `${minutes}:${seconds.toString().padStart(2, "0")}`;
556
+ })()}
557
+ </span>
558
+ </div>
559
+
560
+ {/* bottom right: action buttonz */}
561
+ <div
562
+ class="flex items-center justify-end gap-2"
563
+ style={{ "grid-area": "buttons" }}
564
+ >
565
+ {/* hamburger: open all-playlists overlay */}
566
+ <button
567
+ data-testid="btn-all-playlists"
568
+ aria-expanded={showAllPlaylists()}
569
+ onClick={() => {
570
+ if (showingShare()) closeShare();
571
+ if (editingPlaylist() || editingSong()) handleCloseEdit();
572
+ setShowAllPlaylists((v) => !v);
573
+ }}
574
+ class={`p-2 hover:text-white hover:bg-gray-700 transition-colors bg-black/90 border ${
575
+ showAllPlaylists()
576
+ ? "text-magenta-400 border-magenta-500"
577
+ : "text-gray-400 border-transparent"
578
+ }`}
579
+ title="all playlistz"
580
+ >
581
+ <svg
582
+ class="w-4 h-4"
583
+ fill="none"
584
+ stroke="currentColor"
585
+ viewBox="0 0 24 24"
586
+ >
587
+ <path
588
+ stroke-linecap="round"
589
+ stroke-linejoin="round"
590
+ stroke-width="2"
591
+ d="M4 6h16M4 12h16M4 18h16"
592
+ />
593
+ </svg>
594
+ </button>
595
+
596
+ {/* edit playlist button - toggles edit panel */}
597
+ <button
598
+ data-testid="btn-edit-playlist"
599
+ aria-expanded={editingPlaylist()}
600
+ onClick={() => {
601
+ if (showAllPlaylists()) setShowAllPlaylists(false);
602
+ if (showingShare()) closeShare();
603
+ editingPlaylist()
604
+ ? handleCloseEdit()
605
+ : handleEditPlaylist();
606
+ }}
607
+ class={`p-2 hover:text-white hover:bg-gray-700 transition-colors bg-black/90 border ${editingPlaylist() ? "text-magenta-400 border-magenta-500" : "text-gray-400 border-transparent"}`}
608
+ title={
609
+ editingPlaylist() ? "close edit panel" : "edit playlist"
610
+ }
611
+ >
612
+ <svg
613
+ class="w-4 h-4"
614
+ fill="none"
615
+ stroke="currentColor"
616
+ viewBox="0 0 24 24"
617
+ >
618
+ <path
619
+ stroke-linecap="round"
620
+ stroke-linejoin="round"
621
+ stroke-width="2"
622
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
623
+ />
624
+ </svg>
625
+ </button>
626
+
627
+ {/* share playlist button: icon nodes fill based on connected
628
+ peer count (1/2/3+), pulse when transfers are active */}
629
+ <button
630
+ data-testid="btn-share-playlist"
631
+ aria-expanded={showingShare()}
632
+ onClick={() => {
633
+ if (showingShare()) {
634
+ closeShare();
635
+ } else {
636
+ if (showAllPlaylists()) setShowAllPlaylists(false);
637
+ if (editingPlaylist()) handleCloseEdit();
638
+ setShowingShare(true);
639
+ }
640
+ }}
641
+ class={`relative p-2 hover:text-white hover:bg-gray-700 transition-colors bg-black/90 border ${
642
+ showingShare()
643
+ ? "text-magenta-400 border-magenta-500"
644
+ : sharingReady()
645
+ ? "text-magenta-400 border-transparent"
646
+ : "text-gray-400 border-transparent"
647
+ }`}
648
+ title="share playlist"
649
+ >
650
+ <svg
651
+ class="w-4 h-4"
652
+ fill="none"
653
+ stroke="currentColor"
654
+ viewBox="0 0 24 24"
655
+ >
656
+ {/* connection lines */}
657
+ <line
658
+ x1="7"
659
+ y1="11.5"
660
+ x2="17"
661
+ y2="5.5"
662
+ stroke-width="1.5"
663
+ />
664
+ <line
665
+ x1="7"
666
+ y1="12.5"
667
+ x2="17"
668
+ y2="18.5"
669
+ stroke-width="1.5"
670
+ />
671
+ {/* left node - fills when 1+ connected */}
672
+ <circle
673
+ cx="5"
674
+ cy="12"
675
+ r="2.5"
676
+ stroke-width="1.5"
677
+ fill={connectedPeerCount() >= 1 ? "currentColor" : "none"}
678
+ class={
679
+ connectedPeerCount() >= 1 && isTransferring()
680
+ ? "animate-pulse"
681
+ : ""
682
+ }
683
+ />
684
+ {/* top-right node - fills when 2+ connected */}
685
+ <circle
686
+ cx="19"
687
+ cy="5"
688
+ r="2.5"
689
+ stroke-width="1.5"
690
+ fill={connectedPeerCount() >= 2 ? "currentColor" : "none"}
691
+ class={
692
+ connectedPeerCount() >= 2 && isTransferring()
693
+ ? "animate-pulse"
694
+ : ""
695
+ }
696
+ />
697
+ {/* bottom-right node - fills when 3+ connected */}
698
+ <circle
699
+ cx="19"
700
+ cy="19"
701
+ r="2.5"
702
+ stroke-width="1.5"
703
+ fill={connectedPeerCount() >= 3 ? "currentColor" : "none"}
704
+ class={
705
+ connectedPeerCount() >= 3 && isTransferring()
706
+ ? "animate-pulse"
707
+ : ""
708
+ }
709
+ />
710
+ </svg>
711
+ <Show
712
+ when={pendingKnockCount() > 0 || outboundPendingCount() > 0}
713
+ >
714
+ <span class="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] px-0.5 rounded-full bg-magenta-500 text-white text-[9px] leading-[14px] text-center font-bold">
715
+ {pendingKnockCount() + outboundPendingCount()}
716
+ </span>
717
+ </Show>
718
+ </button>
719
+
720
+ {/* save offline button */}
721
+ <Show
722
+ when={
723
+ window.STANDALONE_MODE &&
724
+ window.location.protocol !== "file:"
725
+ }
726
+ >
727
+ <Show when={!allSongsCached()}>
728
+ <button
729
+ data-testid="btn-cache-offline"
730
+ onClick={handleCachePlaylist}
731
+ disabled={isCaching() || playlistSongs().length === 0}
732
+ class="p-2 text-gray-400 hover:text-magenta-400 hover:bg-gray-700 transition-colors bg-black/90 disabled:opacity-50 disabled:cursor-not-allowed"
733
+ title="download songz for offline use"
734
+ >
735
+ <Show
736
+ when={!isCaching()}
737
+ fallback={
738
+ <svg
739
+ class="w-4 h-4 animate-spin"
740
+ fill="none"
741
+ stroke="currentColor"
742
+ viewBox="0 0 24 24"
743
+ >
744
+ <path
745
+ stroke-linecap="round"
746
+ stroke-linejoin="round"
747
+ stroke-width="2"
748
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
749
+ />
750
+ </svg>
751
+ }
752
+ >
753
+ SAVE OFFLINE
754
+ </Show>
755
+ </button>
756
+ </Show>
757
+ </Show>
758
+
759
+ {/* share playlist (p2p) moved to the edit panel's share
760
+ column - no header share button */}
761
+
762
+ {/* p2p save offline button (fetch missing blobs from peers);
763
+ hidden once everything is already cached locally */}
764
+ <Show
765
+ when={
766
+ !window.STANDALONE_MODE && sharingReady() && p2pHasMissing()
767
+ }
768
+ >
769
+ <button
770
+ data-testid="btn-p2p-save-offline"
771
+ onClick={() => void handleP2pSaveOffline()}
772
+ disabled={p2pSaveProgress() !== null}
773
+ class="p-2 text-gray-400 hover:text-magenta-400 hover:bg-gray-700 transition-colors bg-black/90 disabled:opacity-50"
774
+ title={
775
+ p2pSaveProgress()
776
+ ? `fetching ${p2pSaveProgress()!.currentTitle} (${p2pSaveProgress()!.done}/${p2pSaveProgress()!.total})`
777
+ : "save offline (fetch from peerz)"
778
+ }
779
+ >
780
+ <Show
781
+ when={!p2pSaveProgress()}
782
+ fallback={
783
+ <svg
784
+ class="w-4 h-4 animate-spin"
785
+ fill="none"
786
+ stroke="currentColor"
787
+ viewBox="0 0 24 24"
788
+ >
789
+ <path
790
+ stroke-linecap="round"
791
+ stroke-linejoin="round"
792
+ stroke-width="2"
793
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
794
+ />
795
+ </svg>
796
+ }
797
+ >
798
+ <svg
799
+ class="w-4 h-4"
800
+ fill="none"
801
+ stroke="currentColor"
802
+ viewBox="0 0 24 24"
803
+ >
804
+ <path
805
+ stroke-linecap="round"
806
+ stroke-linejoin="round"
807
+ stroke-width="2"
808
+ d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
809
+ />
810
+ </svg>
811
+ </Show>
812
+ </button>
813
+ </Show>
814
+
815
+ {/* add songs button: opens system file picker, same handler as drag-and-drop.
816
+ hidden in file:// mode (standalone zip) where new songs can't be added. */}
817
+ <Show when={window.location.protocol !== "file:"}>
818
+ <label
819
+ data-testid="btn-add-songs"
820
+ title="add songz"
821
+ class="p-2 text-gray-400 hover:text-green-400 hover:bg-gray-700 transition-colors bg-black/90 cursor-pointer"
822
+ >
823
+ <input
824
+ type="file"
825
+ accept="audio/*,.mp3,.wav,.flac,.ogg,.m4a,.aiff,.zip"
826
+ multiple
827
+ class="hidden"
828
+ onChange={async (e) => {
829
+ const files = Array.from(e.currentTarget.files ?? []);
830
+ if (!files.length) return;
831
+ await dragDrop.processFileImport(files, {
832
+ selectedPlaylist: props.playlist(),
833
+ playlists: playlistManager.playlists(),
834
+ onPlaylistSelected: (p) =>
835
+ playlistManager.selectPlaylist(p),
836
+ });
837
+ e.currentTarget.value = "";
838
+ }}
839
+ />
840
+ <svg
841
+ class="w-4 h-4"
842
+ fill="none"
843
+ stroke="currentColor"
844
+ viewBox="0 0 24 24"
845
+ >
846
+ <path
847
+ stroke-linecap="round"
848
+ stroke-linejoin="round"
849
+ stroke-width="2.5"
850
+ d="M12 4v16m8-8H4"
851
+ />
852
+ </svg>
853
+ </label>
854
+ </Show>
855
+ </div>
856
+ </div>
857
+ );
858
+ }
859
+
860
+ // desktop cover image (right side of the header)
861
+ function coverImage() {
862
+ return (
863
+ <div class={`${isMobile() ? "hidden" : "ml-4"}`}>
864
+ <button
865
+ onClick={() => {
866
+ openImageModal(props.playlist(), playlistSongs(), 0);
867
+ }}
868
+ class="w-39 h-39 overflow-hidden hover:bg-gray-900 flex items-center justify-center transition-colors group"
869
+ style={{
870
+ filter: (() => {
871
+ const p = props.playlist();
872
+ if (p.coverFilterEnabled === false) return "none";
873
+ const blur = p.coverFilterBlur ?? 3;
874
+ return `blur(${blur}px) contrast(3) brightness(0.4)`;
875
+ })(),
876
+ }}
877
+ onMouseEnter={(e) => (e.currentTarget.style.filter = "none")}
878
+ onMouseLeave={(e) => {
879
+ const p = props.playlist();
880
+ if (p.coverFilterEnabled === false) {
881
+ e.currentTarget.style.filter = "none";
882
+ } else {
883
+ const blur = p.coverFilterBlur ?? 3;
884
+ e.currentTarget.style.filter = `blur(${blur}px) contrast(3) brightness(0.4)`;
885
+ }
886
+ }}
887
+ title="view playlist imagez"
888
+ >
889
+ <Show
890
+ when={props.playlist().imageType}
891
+ fallback={
892
+ <div class="text-center">
893
+ <svg
894
+ width="100"
895
+ height="100"
896
+ viewBox="0 0 100 100"
897
+ fill="none"
898
+ xmlns="http://www.w3.org/2000/svg"
899
+ >
900
+ <path
901
+ d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
902
+ fill="#FF00FF"
903
+ />
904
+ </svg>
905
+ </div>
906
+ }
907
+ >
908
+ {(() => {
909
+ const imageUrl = getImageUrlForContext(
910
+ props.playlist(),
911
+ "modal"
912
+ );
913
+ return (
914
+ <>
915
+ {imageUrl ? (
916
+ <img
917
+ src={imageUrl}
918
+ alt="playlist cover"
919
+ class="w-full h-full object-cover"
920
+ />
921
+ ) : (
922
+ <div class="text-center">
923
+ <svg
924
+ width="100"
925
+ height="100"
926
+ viewBox="0 0 100 100"
927
+ fill="none"
928
+ xmlns="http://www.w3.org/2000/svg"
929
+ >
930
+ <path
931
+ d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
932
+ fill="#FF00FF"
933
+ />
934
+ </svg>
935
+ </div>
936
+ )}
937
+ </>
938
+ );
939
+ })()}
940
+ </Show>
941
+ </button>
942
+ </div>
943
+ );
944
+ }
945
+
946
+ return (
947
+ <>
948
+ <Show when={!isMobile()}>{headerSection()}</Show>
949
+
950
+ {/* songz list and edit panels. on mobile the playlist header scrolls
951
+ away with the content while the player controls bar stays sticky */}
952
+ <div
953
+ ref={scrollContainerRef}
954
+ class="flex-1 overflow-y-auto min-h-0"
955
+ >
956
+ <Show when={isMobile()}>
957
+ {headerSection()}
958
+ <div
959
+ ref={stickyBarRef}
960
+ style={headerStyle()}
961
+ class="sticky top-0 z-[110] bg-black py-1"
962
+ >
963
+ {playerControls()}
964
+ </div>
965
+ </Show>
966
+ {/* inline share panel - renders once rows have animated out.
967
+ keyed on playlist id so it remounts when switching playlists */}
968
+ <Show
969
+ when={showingShare() && rowsGone() ? props.playlist().id : null}
970
+ keyed
971
+ >
972
+ <div style={panelEntryStyle()}>
973
+ <PlaylistSharePanel
974
+ playlist={props.playlist}
975
+ playlists={playlists()}
976
+ onClose={closeShare}
977
+ onPlaylistAdded={(docId) => {
978
+ playlistManager.selectById(docId);
979
+ closeShare();
980
+ }}
981
+ />
982
+ </div>
983
+ </Show>
984
+
985
+ {/* inline all-playlists panel - same row-exit animation as edit mode.
986
+ the selected playlist row is not shown (it's the header above).
987
+ edit/share on other rows selects that playlist first. */}
988
+ <Show when={showAllPlaylists() && rowsGone()}>
989
+ <div style={panelEntryStyle()}>
990
+ <AllPlaylistsPanel
991
+ onClose={() => {
992
+ setShowAllPlaylists(false);
993
+ setAllPlaylistsPeerQuery(undefined);
994
+ }}
995
+ onEdit={(p) => {
996
+ playlistManager.selectPlaylist(p);
997
+ setShowAllPlaylists(false);
998
+ setAllPlaylistsPeerQuery(undefined);
999
+ setTimeout(() => handleEditPlaylist(), 0);
1000
+ }}
1001
+ onShare={(p) => {
1002
+ playlistManager.selectPlaylist(p);
1003
+ setShowAllPlaylists(false);
1004
+ setAllPlaylistsPeerQuery(undefined);
1005
+ setTimeout(() => setShowingShare(true), 0);
1006
+ }}
1007
+ onPlaylistAdded={(docId) => {
1008
+ playlistManager.selectById(docId);
1009
+ setShowAllPlaylists(false);
1010
+ setAllPlaylistsPeerQuery(undefined);
1011
+ }}
1012
+ initialQuery={allPlaylistsPeerQuery()}
1013
+ />
1014
+ </div>
1015
+ </Show>
1016
+
1017
+ {/* inline playlist edit panel - only renders once rows have animated out.
1018
+ keyed on playlist id so the form remounts with fresh data when
1019
+ switching playlists via the sidebar */}
1020
+ <Show
1021
+ when={
1022
+ editingPlaylist() && rowsGone() ? props.playlist().id : null
1023
+ }
1024
+ keyed
1025
+ >
1026
+ <div
1027
+ style={panelEntryStyle()}
1028
+ class={isMobile() ? "p-2" : "px-6 pt-2 pb-4"}
1029
+ >
1030
+ <PlaylistEditPanel
1031
+ playlist={props.playlist()}
1032
+ playlistSongs={playlistSongs()}
1033
+ onClose={handleCloseEdit}
1034
+ onSave={(updated) =>
1035
+ playlistManager.selectPlaylist(updated)
1036
+ }
1037
+ onFork={(newDocId) => {
1038
+ playlistManager.selectById(newDocId);
1039
+ handleCloseEdit();
1040
+ }}
1041
+ />
1042
+ </div>
1043
+ </Show>
1044
+
1045
+ {/* inline song edit panel - only renders once rows have animated out.
1046
+ keyed on song id so the form remounts when navigating between songs */}
1047
+ <Show
1048
+ when={editingSong() && rowsGone() ? editingSong()!.id : null}
1049
+ keyed
1050
+ >
1051
+ <div
1052
+ style={panelEntryStyle()}
1053
+ class={isMobile() ? "" : "px-6 pt-2 pb-4"}
1054
+ >
1055
+ <SongEditPanel
1056
+ song={editingSong()!}
1057
+ index={editingSongIndex()}
1058
+ onClose={handleCloseEdit}
1059
+ onSave={handleSongSaved}
1060
+ prevSong={songAtOffset(-1)}
1061
+ nextSong={songAtOffset(1)}
1062
+ onNavigate={handleEditSong}
1063
+ />
1064
+ </div>
1065
+ </Show>
1066
+
1067
+ {/* rows container - no overflow:hidden here; scroll container clips instead.
1068
+ fully removed from layout once rows are gone so leftover padding +
1069
+ space-y margins don't add phantom height below the edit panel */}
1070
+ <div
1071
+ class={`${isMobile() ? "space-y-1" : "p-6 space-y-2"}`}
1072
+ style={rowsGone() ? { display: "none" } : {}}
1073
+ >
1074
+ {/* empty playlist message - hidden during edit mode */}
1075
+ <Show
1076
+ when={
1077
+ !isEditing() &&
1078
+ (!props.playlist().songIds ||
1079
+ props.playlist().songIds.length === 0)
1080
+ }
1081
+ >
1082
+ <div
1083
+ data-testid="empty-songs"
1084
+ class={`${isMobile() ? "" : "ml-42 mr-42"} text-center p-8 bg-black/75`}
1085
+ >
1086
+ <div class="text-gray-400 text-xl mb-4">no songz yet</div>
1087
+ <p class="text-gray-400 mb-4">
1088
+ drag and drop audio filez (or a .zip file!) here to add
1089
+ them to this playlist
1090
+ </p>
1091
+ <div class="text-xs text-gray-500 space-y-1">
1092
+ <div>playlist id: {props.playlist().id}</div>
1093
+ <div>
1094
+ supported formatz: mp3, wav, flac, aiff, ogg, mp4
1095
+ </div>
1096
+ </div>
1097
+ </div>
1098
+ </Show>
1099
+
1100
+ {/* animated song rows: outer wrapper collapses height after animation,
1101
+ inner wrapper runs the CSS keyframe flyout/flyin animation */}
1102
+ <For each={props.playlist().songIds}>
1103
+ {(songId, index) => {
1104
+ const isBeingEdited = () => editingSong()?.id === songId;
1105
+ // sticky has to live on this outer wrapper: the row itself is
1106
+ // boxed in by the animation wrappers, so position: sticky on it
1107
+ // can't escape and never actually sticks
1108
+ const isActiveRow = () =>
1109
+ audioState.selectedSongId() === songId ||
1110
+ (audioState.currentSong()?.id === songId &&
1111
+ audioState.isPlaying());
1112
+ return (
1113
+ <Show when={!isBeingEdited()}>
1114
+ <div
1115
+ style={{
1116
+ ...rowOuterStyle(),
1117
+ ...(isActiveRow()
1118
+ ? { top: `${stickyBarHeight()}px` }
1119
+ : {}),
1120
+ }}
1121
+ class={isActiveRow() ? "sticky bottom-0 z-100" : ""}
1122
+ >
1123
+ <div style={rowInnerStyle(index())}>
1124
+ <SongRow
1125
+ songId={songId}
1126
+ index={index()}
1127
+ showRemoveButton={!isSubscribed()}
1128
+ onRemove={handleRemoveSong}
1129
+ onPlay={handlePlaySongWithPlaylist}
1130
+ onPause={handlePauseSong}
1131
+ onEdit={handleEditSong}
1132
+ onReorder={
1133
+ isSubscribed() ? undefined : handleReorderSongs
1134
+ }
1135
+ />
1136
+ </div>
1137
+ </div>
1138
+ </Show>
1139
+ );
1140
+ }}
1141
+ </For>
1142
+ </div>
1143
+ </div>
1144
+ </>
1145
+ );
1146
+ })()}
1147
+ </div>
1148
+ );
1149
+ }
1150
+
1151
+ // compact read-only banner shown below the title/description for subscribed playlists.
1152
+ // provides quick access to fork (local copy) and the full edit panel (for collab request).
1153
+ function SubscribedBanner(props: {
1154
+ playlist: Playlist;
1155
+ onFork: (newDocId: string) => void;
1156
+ }) {
1157
+ const [forking, setForking] = createSignal(false);
1158
+ const [forkError, setForkError] = createSignal<string | null>(null);
1159
+
1160
+ const handleFork = async () => {
1161
+ if (forking()) return;
1162
+ setForking(true);
1163
+ setForkError(null);
1164
+ try {
1165
+ const forked = await forkPlaylist(props.playlist.id);
1166
+ props.onFork(forked.id);
1167
+ } catch (err) {
1168
+ setForkError("fork failed");
1169
+ console.error("fork error:", err);
1170
+ } finally {
1171
+ setForking(false);
1172
+ }
1173
+ };
1174
+
1175
+ const displayName = () =>
1176
+ props.playlist.remoteName ||
1177
+ props.playlist.remoteNodeId?.slice(0, 16) ||
1178
+ "peer";
1179
+
1180
+ return (
1181
+ <div
1182
+ data-testid="subscribed-banner"
1183
+ class="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1.5 bg-black/70 border-t border-gray-800 text-xs"
1184
+ >
1185
+ <span class="text-yellow-500/80 font-medium">read only</span>
1186
+ <span class="text-gray-600">·</span>
1187
+ <span class="text-gray-500">from {displayName()}</span>
1188
+ <div class="flex items-center gap-2 ml-auto">
1189
+ <button
1190
+ data-testid="btn-fork-playlist-banner"
1191
+ class="px-2 py-0.5 text-gray-300 hover:text-white border border-gray-700 hover:border-gray-500 disabled:opacity-50 transition-colors"
1192
+ onClick={() => void handleFork()}
1193
+ disabled={forking()}
1194
+ >
1195
+ {forking() ? "forking..." : "fork my copy"}
1196
+ </button>
1197
+ </div>
1198
+ <Show when={forkError()}>
1199
+ <span class="w-full text-red-400">{forkError()}</span>
1200
+ </Show>
1201
+ </div>
1202
+ );
1203
+ }