@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,713 @@
1
+ // inline all-playlists panel. replaces song rows when the hamburger is pressed.
2
+ //
3
+ // - the currently selected playlist is NOT shown (it's in the header above)
4
+ // - each row: thumbnail, title+description marquee, total time, song count,
5
+ // action buttons (edit, share, download zip)
6
+ // - "new playlist" sticky row at the bottom
7
+ // - title/description text wrapped in tight bg-black spans for legibility
8
+ // over the transparent/blurred playlist background
9
+
10
+ import { For, Show, createSignal, onMount } from "solid-js";
11
+ import {
12
+ createRelativeTimeSignal,
13
+ formatDuration,
14
+ } from "../utils/timeUtils.js";
15
+ import { getImageUrlForContext } from "../services/imageService.js";
16
+ import { audioState, playPlaylist } from "../services/audioService.js";
17
+ import { downloadPlaylistAsZip } from "../services/playlistDownloadService.js";
18
+ import type { Playlist, Song } from "../types/playlist.js";
19
+ import { usePlaylistzManager } from "../context/PlaylistzContext.js";
20
+ import { MarqueeText } from "./MarqueeText.js";
21
+ import { getSongsForPlaylist } from "../services/playlistDocService.js";
22
+ import {
23
+ openShareLink,
24
+ queryPeerPlaylists,
25
+ ensureSharingReady,
26
+ knockOnPeer,
27
+ type PeerPlaylistListing,
28
+ } from "../services/sharingService.js";
29
+ import { decodeShareToken } from "@freqhole/api-client/playlistz";
30
+ import { ShareLinkKnockPanel } from "./ShareLinkKnockPanel.js";
31
+
32
+ interface Props {
33
+ onClose: () => void;
34
+ // select a different playlist + open edit mode
35
+ onEdit: (p: Playlist) => void;
36
+ // select a different playlist + open share panel
37
+ onShare: (p: Playlist) => void;
38
+ // called when a share link is successfully opened from the search bar
39
+ onPlaylistAdded?: (docId: string) => void;
40
+ // pre-fill search with a peer nodeId and trigger peer browse on open
41
+ initialQuery?: string;
42
+ }
43
+
44
+ export function AllPlaylistsPanel(props: Props) {
45
+ const {
46
+ playlists,
47
+ selectedPlaylist,
48
+ selectPlaylist,
49
+ selectById,
50
+ createNewPlaylist,
51
+ } = usePlaylistzManager();
52
+
53
+ const [isCreating, setIsCreating] = createSignal(false);
54
+ const [allSongs, setAllSongs] = createSignal<Record<string, Song[]>>({});
55
+ const [query, setQuery] = createSignal(props.initialQuery ?? "");
56
+ const [searchStatus, setSearchStatus] = createSignal<string | null>(null);
57
+ const [peerListing, setPeerListing] =
58
+ createSignal<PeerPlaylistListing | null>(null);
59
+
60
+ // knock modal state for knock-gated share links pasted into the search bar
61
+ const [searchKnockRequired, setSearchKnockRequired] = createSignal<{
62
+ ownerNodeId: string;
63
+ docId: string;
64
+ title?: string;
65
+ ownerName?: string;
66
+ } | null>(null);
67
+
68
+ // knock-with-message state (shown when knockRequired)
69
+ const [knockMessage, setKnockMessage] = createSignal("");
70
+ const [isKnocking, setIsKnocking] = createSignal(false);
71
+ const [knockStatus, setKnockStatus] = createSignal<string | null>(null);
72
+
73
+ // detect if a string is a hex iroh node id (64 lowercase hex chars)
74
+ const isNodeId = (s: string) => /^[0-9a-f]{64}$/i.test(s.trim());
75
+
76
+ // detect share links via decodeShareToken
77
+ const isShareLink = (s: string) => decodeShareToken(s.trim()) !== null;
78
+
79
+ // exclude the currently selected playlist - it stays in the header above
80
+ const otherPlaylists = () => {
81
+ const sel = selectedPlaylist();
82
+ const all = sel ? playlists().filter((p) => p.id !== sel.id) : playlists();
83
+ const q = query().trim().toLowerCase();
84
+ // when in peer browse mode or empty query, show all; otherwise filter
85
+ if (!q || peerListing()) return all;
86
+ return all.filter(
87
+ (p) =>
88
+ p.title.toLowerCase().includes(q) ||
89
+ (p.description ?? "").toLowerCase().includes(q)
90
+ );
91
+ };
92
+
93
+ const handleSearchInput = async (value: string) => {
94
+ setQuery(value);
95
+ setSearchStatus(null);
96
+ setPeerListing(null);
97
+
98
+ const trimmed = value.trim();
99
+ if (!trimmed) return;
100
+
101
+ if (isShareLink(trimmed)) {
102
+ setSearchStatus("opening...");
103
+ try {
104
+ const result = await openShareLink(trimmed);
105
+ if (result.status === "knock_required") {
106
+ setSearchStatus(null);
107
+ setQuery("");
108
+ setSearchKnockRequired(result);
109
+ return;
110
+ }
111
+ setQuery("");
112
+ setSearchStatus(null);
113
+ selectById(result.docId);
114
+ props.onPlaylistAdded?.(result.docId);
115
+ props.onClose();
116
+ } catch (err) {
117
+ setSearchStatus(
118
+ err instanceof Error ? err.message : "could not open share link"
119
+ );
120
+ }
121
+ return;
122
+ }
123
+
124
+ if (isNodeId(trimmed)) {
125
+ setSearchStatus("connecting to peer...");
126
+ try {
127
+ await ensureSharingReady();
128
+ const listing = await queryPeerPlaylists(trimmed);
129
+ setPeerListing(listing);
130
+ setSearchStatus(null);
131
+ } catch (err) {
132
+ setSearchStatus(
133
+ err instanceof Error ? err.message : "could not reach peer"
134
+ );
135
+ }
136
+ }
137
+ };
138
+
139
+ onMount(() => {
140
+ // if a peer nodeId was provided, trigger the peer browse immediately
141
+ if (props.initialQuery) {
142
+ void handleSearchInput(props.initialQuery);
143
+ }
144
+
145
+ const visible = otherPlaylists();
146
+ void Promise.allSettled(
147
+ visible.map(async (p) => {
148
+ const songs = await getSongsForPlaylist(p.id);
149
+ setAllSongs((prev) => ({ ...prev, [p.id]: songs }));
150
+ })
151
+ );
152
+ });
153
+
154
+ const handleSelect = (p: Playlist) => {
155
+ selectPlaylist(p);
156
+ props.onClose();
157
+ };
158
+
159
+ const handlePlay = (p: Playlist) => {
160
+ selectPlaylist(p);
161
+ props.onClose();
162
+ void playPlaylist(p);
163
+ };
164
+
165
+ const handleCreate = async () => {
166
+ if (isCreating()) return;
167
+ setIsCreating(true);
168
+ try {
169
+ const created = await createNewPlaylist("new playlist");
170
+ if (created) {
171
+ selectPlaylist(created);
172
+ props.onClose();
173
+ }
174
+ } finally {
175
+ setIsCreating(false);
176
+ }
177
+ };
178
+
179
+ return (
180
+ <div class="flex flex-col h-full" data-testid="all-playlists-panel">
181
+ {/* knock modal for knock-gated share links pasted into search */}
182
+ <Show when={searchKnockRequired()}>
183
+ <ShareLinkKnockPanel
184
+ ownerNodeId={searchKnockRequired()!.ownerNodeId}
185
+ docId={searchKnockRequired()!.docId}
186
+ title={searchKnockRequired()!.title}
187
+ ownerName={searchKnockRequired()!.ownerName}
188
+ onAccepted={(docId) => {
189
+ setSearchKnockRequired(null);
190
+ selectById(docId);
191
+ props.onPlaylistAdded?.(docId);
192
+ props.onClose();
193
+ }}
194
+ onDismiss={() => setSearchKnockRequired(null)}
195
+ />
196
+ </Show>
197
+ {/* always-visible search input */}
198
+ <div class="px-3 pt-2 pb-1 flex-shrink-0">
199
+ <input
200
+ data-testid="input-search-playlists"
201
+ type="text"
202
+ value={query()}
203
+ placeholder="search, paste share link, or node id..."
204
+ onInput={(e) => void handleSearchInput(e.currentTarget.value)}
205
+ onKeyDown={(e) => {
206
+ if (e.key === "Escape") {
207
+ setQuery("");
208
+ setPeerListing(null);
209
+ setSearchStatus(null);
210
+ }
211
+ }}
212
+ class="w-full bg-black/60 text-white px-3 py-2 text-xs border border-white/10 hover:border-white/30 focus:border-magenta-500 focus:outline-none placeholder-gray-600 transition-colors"
213
+ />
214
+ <Show when={searchStatus()}>
215
+ <div class="mt-1 text-xs text-magenta-400 px-1">
216
+ <span class="bg-black/80 px-1">{searchStatus()}</span>
217
+ </div>
218
+ </Show>
219
+ <Show when={peerListing()?.knockRequired}>
220
+ <div class="mt-2 px-3 space-y-1.5">
221
+ <p class="text-xs text-yellow-500 bg-black/80 px-1">
222
+ this peer requires a knock to view their playlistz
223
+ </p>
224
+ <textarea
225
+ data-testid="input-knock-message"
226
+ value={knockMessage()}
227
+ onInput={(e) => setKnockMessage(e.currentTarget.value)}
228
+ placeholder="say who you are and why you're knocking..."
229
+ rows={2}
230
+ class="w-full bg-black/60 text-white px-2 py-1.5 text-xs border border-white/10 focus:border-magenta-500 focus:outline-none placeholder-gray-600 resize-none"
231
+ />
232
+ <button
233
+ data-testid="btn-send-knock"
234
+ onClick={async () => {
235
+ const nodeId = query().trim();
236
+ if (!nodeId || isKnocking()) return;
237
+ setIsKnocking(true);
238
+ setKnockStatus(null);
239
+ try {
240
+ await ensureSharingReady();
241
+ await knockOnPeer(nodeId, knockMessage() || undefined);
242
+ setKnockStatus("knock sent - waiting for owner to accept");
243
+ } catch (err) {
244
+ setKnockStatus(
245
+ err instanceof Error ? err.message : "knock failed"
246
+ );
247
+ } finally {
248
+ setIsKnocking(false);
249
+ }
250
+ }}
251
+ disabled={isKnocking()}
252
+ class="w-full px-3 py-1.5 bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-white text-xs transition-colors border border-white/10"
253
+ >
254
+ {isKnocking() ? "knocking..." : "send knock"}
255
+ </button>
256
+ <Show when={knockStatus()}>
257
+ <p class="text-xs text-magenta-400 bg-black/80 px-1">
258
+ {knockStatus()}
259
+ </p>
260
+ </Show>
261
+ </div>
262
+ </Show>
263
+ </div>
264
+
265
+ <div class="flex-1 overflow-y-auto">
266
+ {/* peer browse mode: show remote playlists */}
267
+ <Show
268
+ when={peerListing()}
269
+ fallback={
270
+ <>
271
+ <Show when={otherPlaylists().length > 0}>
272
+ <For each={otherPlaylists()}>
273
+ {(p) => (
274
+ <PlaylistRow
275
+ playlist={p}
276
+ songs={allSongs()[p.id]}
277
+ onSelect={handleSelect}
278
+ onPlay={handlePlay}
279
+ onEdit={props.onEdit}
280
+ onShare={props.onShare}
281
+ onBrowsePeer={(nodeId) => {
282
+ setQuery(nodeId);
283
+ void handleSearchInput(nodeId);
284
+ }}
285
+ />
286
+ )}
287
+ </For>
288
+ </Show>
289
+ </>
290
+ }
291
+ >
292
+ {(listing) => (
293
+ <Show
294
+ when={listing().items.length > 0}
295
+ fallback={
296
+ <div class="px-4 py-3 text-xs text-gray-500">
297
+ <span class="bg-black/80 px-1">
298
+ no playlistz shared by this peer
299
+ </span>
300
+ </div>
301
+ }
302
+ >
303
+ <div class="px-3 pt-1 pb-0.5 text-xs text-gray-500">
304
+ <span class="bg-black/80 px-1">
305
+ {listing().name
306
+ ? `${listing().name}'s playlistz`
307
+ : "peer's playlistz"}
308
+ </span>
309
+ </div>
310
+ <For each={listing().items}>
311
+ {(item) => (
312
+ <PeerPlaylistRow
313
+ item={item}
314
+ nodeId={listing().nodeId}
315
+ onAdd={async (docId) => {
316
+ selectById(docId);
317
+ props.onPlaylistAdded?.(docId);
318
+ props.onClose();
319
+ }}
320
+ onError={(msg) => setSearchStatus(msg)}
321
+ />
322
+ )}
323
+ </For>
324
+ </Show>
325
+ )}
326
+ </Show>
327
+
328
+ {/* sticky new-playlist row */}
329
+ <div class="sticky bottom-0">
330
+ <button
331
+ data-testid="btn-new-playlist"
332
+ onClick={handleCreate}
333
+ disabled={isCreating()}
334
+ class="w-full flex items-center gap-3 px-4 py-3 text-gray-500 hover:text-white hover:bg-magenta-500/75 disabled:opacity-50 transition-colors border-t border-white/10 bg-black/40 text-sm"
335
+ >
336
+ <div class="flex-shrink-0 w-10 h-10 flex items-center justify-center border border-dashed border-gray-600">
337
+ <Show
338
+ when={!isCreating()}
339
+ fallback={
340
+ <div class="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
341
+ }
342
+ >
343
+ <svg
344
+ class="w-4 h-4"
345
+ fill="none"
346
+ stroke="currentColor"
347
+ viewBox="0 0 24 24"
348
+ >
349
+ <path
350
+ stroke-linecap="round"
351
+ stroke-linejoin="round"
352
+ stroke-width="2"
353
+ d="M12 4v16m8-8H4"
354
+ />
355
+ </svg>
356
+ </Show>
357
+ </div>
358
+ <span class="px-1 py-0.5 bg-black text-white">
359
+ {isCreating() ? "creating..." : "new playlist"}
360
+ </span>
361
+ </button>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ function PeerPlaylistRow(props: {
369
+ item: PeerPlaylistListing["items"][number];
370
+ nodeId: string;
371
+ onAdd: (docId: string) => Promise<void>;
372
+ onError: (msg: string) => void;
373
+ }) {
374
+ const [adding, setAdding] = createSignal(false);
375
+
376
+ const handleAdd = async () => {
377
+ if (adding()) return;
378
+ setAdding(true);
379
+ try {
380
+ const token = btoa(
381
+ JSON.stringify({
382
+ v: 1,
383
+ n: props.nodeId,
384
+ d: props.item.docId,
385
+ t: props.item.title,
386
+ })
387
+ )
388
+ .replace(/\+/g, "-")
389
+ .replace(/\//g, "_")
390
+ .replace(/=/g, "");
391
+ const result = await openShareLink(`#share/${token}`);
392
+ if (result.status === "synced") await props.onAdd(result.docId);
393
+ } catch (err) {
394
+ props.onError(
395
+ err instanceof Error ? err.message : "failed to add playlist"
396
+ );
397
+ } finally {
398
+ setAdding(false);
399
+ }
400
+ };
401
+
402
+ return (
403
+ <div class="group flex items-center gap-3 px-4 py-3 hover:bg-magenta-500/75 transition-colors">
404
+ {/* placeholder thumbnail */}
405
+ <div class="flex-shrink-0 w-10 h-10 bg-black/40 flex items-center justify-center">
406
+ <svg width="20" height="20" viewBox="0 0 100 100" fill="none">
407
+ <path d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z" fill="#FF00FF" />
408
+ </svg>
409
+ </div>
410
+ <div class="flex-1 min-w-0">
411
+ <div class="text-sm font-medium text-white truncate">
412
+ <span class="bg-black px-1">{props.item.title}</span>
413
+ </div>
414
+ <div class="text-xs text-gray-500 mt-0.5">
415
+ <span class="bg-black px-1">
416
+ {props.item.songCount === 1
417
+ ? "1 song"
418
+ : `${props.item.songCount} songz`}
419
+ </span>
420
+ </div>
421
+ </div>
422
+ <button
423
+ class="flex-shrink-0 px-3 py-1 text-xs border border-magenta-500 text-magenta-400 hover:bg-magenta-500/20 disabled:opacity-50 transition-colors"
424
+ onClick={() => void handleAdd()}
425
+ disabled={adding()}
426
+ >
427
+ {adding() ? "adding..." : "add"}
428
+ </button>
429
+ </div>
430
+ );
431
+ }
432
+
433
+ function PlaylistRow(props: {
434
+ playlist: Playlist;
435
+ songs?: Song[];
436
+ onSelect: (p: Playlist) => void;
437
+ onPlay: (p: Playlist) => void;
438
+ onEdit: (p: Playlist) => void;
439
+ onShare: (p: Playlist) => void;
440
+ // called when user clicks the sharer identity pill
441
+ onBrowsePeer?: (nodeId: string) => void;
442
+ }) {
443
+ const isPlaying = () =>
444
+ audioState.isPlaying() &&
445
+ audioState.currentPlaylist()?.id === props.playlist.id;
446
+
447
+ const relativeTime = createRelativeTimeSignal(props.playlist.updatedAt);
448
+
449
+ const songCount = () => {
450
+ const n = props.playlist.songIds?.length ?? 0;
451
+ return n === 1 ? "1 song" : `${n} songz`;
452
+ };
453
+
454
+ const totalTime = () => {
455
+ const songs = props.songs;
456
+ if (!songs || songs.length === 0) return null;
457
+ const secs = songs.reduce((t, s) => t + (s.duration || 0), 0);
458
+ if (secs === 0) return null;
459
+ return formatDuration(secs);
460
+ };
461
+
462
+ const imageUrl = () => getImageUrlForContext(props.playlist, "thumbnail");
463
+
464
+ const [isHovered, setIsHovered] = createSignal(false);
465
+ const [downloading, setDownloading] = createSignal(false);
466
+
467
+ const handleDownload = async (e: MouseEvent) => {
468
+ e.stopPropagation();
469
+ if (downloading()) return;
470
+ setDownloading(true);
471
+ try {
472
+ await downloadPlaylistAsZip(props.playlist, {
473
+ includeMetadata: true,
474
+ includeImages: true,
475
+ generateM3U: true,
476
+ includeHTML: true,
477
+ });
478
+ } finally {
479
+ setDownloading(false);
480
+ }
481
+ };
482
+
483
+ return (
484
+ <div
485
+ class="group relative flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors hover:bg-magenta-500/75"
486
+ onClick={() => props.onSelect(props.playlist)}
487
+ onDblClick={(e) => {
488
+ e.stopPropagation();
489
+ props.onPlay(props.playlist);
490
+ }}
491
+ onMouseEnter={() => setIsHovered(true)}
492
+ onMouseLeave={() => setIsHovered(false)}
493
+ >
494
+ {/* thumbnail */}
495
+ <div class="relative flex-shrink-0 w-10 h-10 overflow-hidden bg-black/40">
496
+ <Show when={isPlaying()}>
497
+ <div
498
+ data-testid="row-playing-indicator"
499
+ class="absolute inset-0 z-10 flex items-center justify-center bg-black/50"
500
+ title="playing"
501
+ >
502
+ <svg
503
+ class="w-4 h-4 text-magenta-400 animate-pulse"
504
+ fill="currentColor"
505
+ viewBox="0 0 20 20"
506
+ >
507
+ <path d="M6.3 4.06a1 1 0 011.02.04l7 4.5a1 1 0 010 1.7l-7 4.5A1 1 0 016 14V5a1 1 0 01.3-.94z" />
508
+ </svg>
509
+ </div>
510
+ </Show>
511
+ <Show
512
+ when={imageUrl()}
513
+ fallback={
514
+ <div class="w-full h-full flex items-center justify-center">
515
+ <svg width="24" height="24" viewBox="0 0 100 100" fill="none">
516
+ <path
517
+ d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
518
+ fill="#FF00FF"
519
+ />
520
+ </svg>
521
+ </div>
522
+ }
523
+ >
524
+ <img
525
+ src={imageUrl()!}
526
+ alt={props.playlist.title}
527
+ class="w-full h-full object-cover"
528
+ />
529
+ </Show>
530
+ </div>
531
+
532
+ {/* text block */}
533
+ <div class="flex-1 min-w-0 overflow-hidden">
534
+ <MarqueeText
535
+ text={props.playlist.title}
536
+ isHovering={isHovered}
537
+ class="text-sm font-medium text-white [&>span]:px-1 [&>span]:bg-black"
538
+ />
539
+ <Show when={props.playlist.description}>
540
+ <MarqueeText
541
+ text={props.playlist.description!}
542
+ isHovering={isHovered}
543
+ class="text-xs text-gray-400 mt-0.5 [&>span]:px-1 [&>span]:bg-black"
544
+ />
545
+ </Show>
546
+ <div class="flex items-center gap-1 mt-0.5 flex-wrap">
547
+ <span
548
+ data-testid="row-song-count"
549
+ class="text-xs text-gray-500 px-1 bg-black"
550
+ >
551
+ {songCount()}
552
+ </span>
553
+ <Show when={totalTime()}>
554
+ <span class="text-xs text-gray-700 bg-black px-0.5">·</span>
555
+ <span class="text-xs text-gray-500 px-1 bg-black">
556
+ {totalTime()}
557
+ </span>
558
+ </Show>
559
+ <span class="text-xs text-gray-700 bg-black px-0.5">·</span>
560
+ <span class="text-xs text-gray-500 px-1 bg-black">
561
+ {relativeTime.signal()}
562
+ </span>
563
+ <Show when={props.playlist.remoteNodeId}>
564
+ <span class="text-xs text-gray-700 bg-black px-0.5">·</span>
565
+ <button
566
+ data-testid="btn-browse-sharer"
567
+ class="flex items-center gap-0.5 text-xs text-gray-500 px-1 bg-black hover:text-magenta-400 transition-colors"
568
+ title={`browse ${props.playlist.remoteName || props.playlist.remoteNodeId?.slice(0, 16)}'s playlistz`}
569
+ onClick={(e) => {
570
+ e.stopPropagation();
571
+ if (props.playlist.remoteNodeId)
572
+ props.onBrowsePeer?.(props.playlist.remoteNodeId);
573
+ }}
574
+ >
575
+ <Show
576
+ when={props.playlist.remoteAvatarDataUrl}
577
+ fallback={
578
+ <span class="inline-flex items-center justify-center w-3 h-3 bg-magenta-700/60 text-white text-[7px] font-bold rounded-full overflow-hidden">
579
+ {(
580
+ props.playlist.remoteName ||
581
+ props.playlist.remoteNodeId ||
582
+ ""
583
+ )
584
+ .slice(0, 1)
585
+ .toUpperCase()}
586
+ </span>
587
+ }
588
+ >
589
+ <img
590
+ src={props.playlist.remoteAvatarDataUrl}
591
+ alt={props.playlist.remoteName || "peer"}
592
+ class="w-3 h-3 rounded-full object-cover"
593
+ />
594
+ </Show>
595
+ <span>
596
+ {props.playlist.remoteName ||
597
+ props.playlist.remoteNodeId?.slice(0, 8)}
598
+ </span>
599
+ </button>
600
+ </Show>
601
+ </div>
602
+ </div>
603
+
604
+ {/* action buttons - fade in on hover, solid black bg for legibility */}
605
+ <div
606
+ class={`flex-shrink-0 flex items-center bg-black transition-opacity ${
607
+ isHovered() ? "opacity-100" : "opacity-0"
608
+ }`}
609
+ >
610
+ <button
611
+ data-testid="btn-play-playlist-row"
612
+ class="p-3 text-gray-500 hover:text-magenta-400 transition-colors"
613
+ title={`play ${props.playlist.title}`}
614
+ onClick={(e) => {
615
+ e.stopPropagation();
616
+ props.onPlay(props.playlist);
617
+ }}
618
+ >
619
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
620
+ <path d="M6.3 4.06a1 1 0 011.02.04l7 4.5a1 1 0 010 1.7l-7 4.5A1 1 0 016 14V5a1 1 0 01.3-.94z" />
621
+ </svg>
622
+ </button>
623
+ <button
624
+ data-testid="btn-edit-playlist-row"
625
+ class="p-3 text-gray-500 hover:text-white transition-colors"
626
+ title="edit playlist"
627
+ onClick={(e) => {
628
+ e.stopPropagation();
629
+ props.onEdit(props.playlist);
630
+ }}
631
+ >
632
+ <svg
633
+ class="w-4 h-4"
634
+ fill="none"
635
+ stroke="currentColor"
636
+ viewBox="0 0 24 24"
637
+ >
638
+ <path
639
+ stroke-linecap="round"
640
+ stroke-linejoin="round"
641
+ stroke-width="2"
642
+ 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"
643
+ />
644
+ </svg>
645
+ </button>
646
+ <button
647
+ data-testid="btn-share-playlist-row"
648
+ class="p-3 text-gray-500 hover:text-magenta-400 transition-colors"
649
+ title="share playlist"
650
+ onClick={(e) => {
651
+ e.stopPropagation();
652
+ props.onShare(props.playlist);
653
+ }}
654
+ >
655
+ <svg
656
+ class="w-4 h-4"
657
+ fill="none"
658
+ stroke="currentColor"
659
+ viewBox="0 0 24 24"
660
+ >
661
+ <line x1="7" y1="11.5" x2="17" y2="5.5" stroke-width="1.5" />
662
+ <line x1="7" y1="12.5" x2="17" y2="18.5" stroke-width="1.5" />
663
+ <circle cx="5" cy="12" r="2.5" stroke-width="1.5" />
664
+ <circle cx="19" cy="5" r="2.5" stroke-width="1.5" />
665
+ <circle cx="19" cy="19" r="2.5" stroke-width="1.5" />
666
+ </svg>
667
+ </button>
668
+ <Show when={window.location.protocol !== "file:"}>
669
+ <button
670
+ data-testid="btn-download-zip-row"
671
+ class="p-3 text-gray-500 hover:text-green-400 transition-colors disabled:opacity-40"
672
+ title="download playlist as zip"
673
+ disabled={downloading()}
674
+ onClick={handleDownload}
675
+ >
676
+ <Show
677
+ when={!downloading()}
678
+ fallback={
679
+ <svg
680
+ class="w-4 h-4 animate-spin"
681
+ fill="none"
682
+ stroke="currentColor"
683
+ viewBox="0 0 24 24"
684
+ >
685
+ <path
686
+ stroke-linecap="round"
687
+ stroke-linejoin="round"
688
+ stroke-width="2"
689
+ 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"
690
+ />
691
+ </svg>
692
+ }
693
+ >
694
+ <svg
695
+ class="w-4 h-4"
696
+ fill="none"
697
+ stroke="currentColor"
698
+ viewBox="0 0 24 24"
699
+ >
700
+ <path
701
+ stroke-linecap="round"
702
+ stroke-linejoin="round"
703
+ stroke-width="2"
704
+ 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"
705
+ />
706
+ </svg>
707
+ </Show>
708
+ </button>
709
+ </Show>
710
+ </div>
711
+ </div>
712
+ );
713
+ }