@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,689 @@
1
+ import {
2
+ createSignal,
3
+ createResource,
4
+ Show,
5
+ onMount,
6
+ onCleanup,
7
+ } from "solid-js";
8
+ import { getSongById } from "../services/playlistDocService.js";
9
+ import { enrichSongsWithStandalonePaths } from "../services/standaloneService.js";
10
+ import { createRelativeTimeSignal } from "../utils/timeUtils.js";
11
+ import { getSongSpecificTrigger } from "../services/songReactivity.js";
12
+ import {
13
+ audioState,
14
+ getSongDownloadProgress,
15
+ isSongCaching,
16
+ seek,
17
+ } from "../services/audioService.js";
18
+ import { getImageUrlForContext } from "../services/imageService.js";
19
+ import {
20
+ isBlobCachedLocally,
21
+ blobDownloadStates,
22
+ fetchSongBlob,
23
+ } from "../services/blobTransferService.js";
24
+ import type { Song } from "../types/playlist.js";
25
+
26
+ interface SongRowProps {
27
+ songId: string;
28
+ index: number;
29
+ onPlay?: (song: Song) => void;
30
+ onPause?: () => void;
31
+ onRemove?: (songId: string) => void;
32
+ onEdit?: (song: Song) => void;
33
+ onReorder?: (fromIndex: number, toIndex: number) => void;
34
+ showRemoveButton?: boolean;
35
+ }
36
+
37
+ export function SongRow(props: SongRowProps) {
38
+ const [isHovered, setIsHovered] = createSignal(false);
39
+ const [isDragging, setIsDragging] = createSignal(false);
40
+ const [draggedOver, setDraggedOver] = createSignal(false);
41
+ const [isMobile, setIsMobile] = createSignal(false);
42
+ const [isSeekBarActive, setIsSeekBarActive] = createSignal(false);
43
+
44
+ // check if device has touch capability
45
+ // this is slightly different than other isMobile varz :/
46
+ // this could probably be in hooks/ so it's the same everywhere...
47
+ onMount(() => {
48
+ setIsMobile("ontouchstart" in window || navigator.maxTouchPoints > 0);
49
+
50
+ // add global dragend cleanup to prevent stuck borders
51
+ const handleGlobalDragEnd = () => {
52
+ setDraggedOver(false);
53
+ setIsDragging(false);
54
+ };
55
+
56
+ document.addEventListener("dragend", handleGlobalDragEnd);
57
+
58
+ onCleanup(() => {
59
+ document.removeEventListener("dragend", handleGlobalDragEnd);
60
+ });
61
+ });
62
+
63
+ // fetch song data with reactivity to specific song updates only
64
+ const [song] = createResource(
65
+ () => [props.songId, getSongSpecificTrigger(props.songId)()] as const,
66
+ async ([songId, _trigger]) => {
67
+ try {
68
+ const fetchedSong = await getSongById(songId);
69
+ if (!fetchedSong) {
70
+ return null;
71
+ }
72
+ return enrichSongsWithStandalonePaths([fetchedSong])[0] ?? null;
73
+ } catch (error) {
74
+ console.error(`Error fetching song ${songId}:`, error);
75
+ return null;
76
+ }
77
+ }
78
+ );
79
+
80
+ // track if this song is currently playing
81
+ const isCurrentlyPlaying = () => {
82
+ const current = audioState.currentSong();
83
+ const playing = audioState.isPlaying();
84
+ return current?.id === props.songId && playing;
85
+ };
86
+
87
+ // track if this song is currently selected (should show selected UI)
88
+ const isCurrentlySelected = () => {
89
+ return audioState.selectedSongId() === props.songId;
90
+ };
91
+
92
+ // track if this song is currently loading or being preloaded
93
+ const isCurrentlyLoading = () => {
94
+ return audioState.loadingSongIds().has(props.songId);
95
+ };
96
+
97
+ // track if this song is being preloaded
98
+ const isPreloading = () => {
99
+ return audioState.preloadingSongId() === props.songId;
100
+ };
101
+
102
+ // track download progress
103
+ const downloadProgress = () => {
104
+ return getSongDownloadProgress(props.songId);
105
+ };
106
+
107
+ // track if this song is being cached
108
+ const isCachingActive = () => {
109
+ return isSongCaching(props.songId);
110
+ };
111
+
112
+ // check if the audio blob is cached locally.
113
+ // source depends on the song's resolved sha so the check only runs once
114
+ // the song data is actually available (avoids a race where song() is still
115
+ // loading, sha is undefined, and we incorrectly treat the song as cached).
116
+ // also re-checks whenever the song-specific trigger fires (e.g. after a
117
+ // p2p blob download completes).
118
+ const songSha = () => song()?.sha ?? song()?.sha256;
119
+ const [blobCached] = createResource(
120
+ () => {
121
+ const sha = songSha();
122
+ if (!sha) return null; // wait for song to load before checking
123
+ return [sha, getSongSpecificTrigger(props.songId)()] as const;
124
+ },
125
+ async ([sha]) => isBlobCachedLocally(sha)
126
+ );
127
+
128
+ // track if the blob for this song is actively being fetched from a peer
129
+ const blobDownloadState = () => {
130
+ const sha = songSha();
131
+ return sha ? (blobDownloadStates().get(sha) ?? null) : null;
132
+ };
133
+
134
+ // standalone mode: the song has a local file path (zip bundle or file://)
135
+ const isStandalone = () =>
136
+ !!song()?.standaloneFilePath ||
137
+ window.location.protocol === "file:" ||
138
+ !!window.STANDALONE_MODE;
139
+
140
+ const formatDuration = (seconds: number | undefined) => {
141
+ if (!seconds) return "0:00";
142
+ const mins = Math.floor(seconds / 60);
143
+ const secs = Math.floor(seconds % 60);
144
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
145
+ };
146
+
147
+ const formatTime = (seconds: number) => {
148
+ const mins = Math.floor(seconds / 60);
149
+ const secs = Math.floor(seconds % 60);
150
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
151
+ };
152
+
153
+ const handlePlayPause = () => {
154
+ const songData = song();
155
+ if (!songData) return;
156
+
157
+ if (isCurrentlyPlaying()) {
158
+ props.onPause?.();
159
+ } else {
160
+ props.onPlay?.(songData);
161
+ }
162
+ };
163
+
164
+ const handleDragStart = (e: DragEvent) => {
165
+ setIsDragging(true);
166
+ e.dataTransfer!.effectAllowed = "move";
167
+ e.dataTransfer!.setData("text/plain", props.index.toString());
168
+ };
169
+
170
+ const handleDragEnd = () => {
171
+ setIsDragging(false);
172
+ setDraggedOver(false);
173
+ };
174
+
175
+ const handleDragOver = (e: DragEvent) => {
176
+ e.preventDefault();
177
+ e.stopPropagation(); // prevent global handler from firing!
178
+ e.dataTransfer!.dropEffect = "move";
179
+ setDraggedOver(true);
180
+ };
181
+
182
+ const handleDragLeave = (e: DragEvent) => {
183
+ e.stopPropagation(); // prevent global handler from firing!
184
+ // more reliable check for actually leaving the element
185
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
186
+ const x = e.clientX;
187
+ const y = e.clientY;
188
+
189
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
190
+ setDraggedOver(false);
191
+ }
192
+ };
193
+
194
+ const handleDrop = (e: DragEvent) => {
195
+ e.preventDefault();
196
+ e.stopPropagation(); // prevent global handler from firing!
197
+ setDraggedOver(false);
198
+
199
+ const fromIndex = parseInt(e.dataTransfer!.getData("text/plain"), 10);
200
+ const toIndex = props.index;
201
+
202
+ if (fromIndex !== toIndex && props.onReorder) {
203
+ props.onReorder(fromIndex, toIndex);
204
+ }
205
+ };
206
+
207
+ const handleEditSong = () => {
208
+ const songData = song();
209
+ if (songData) {
210
+ props.onEdit?.(songData);
211
+ }
212
+ };
213
+
214
+ return (
215
+ <Show
216
+ when={!song.loading}
217
+ fallback={
218
+ <div class="flex items-center p-3 bg-gray-800 bg-opacity-30 animate-pulse">
219
+ <div class="w-12 h-12 bg-gray-700 mr-4" />
220
+ <div class="flex-1">
221
+ <div class="h-4 bg-gray-700 rounded mb-2 w-3/4" />
222
+ <div class="h-3 bg-gray-700 rounded w-1/2" />
223
+ </div>
224
+ <div class="w-16 h-4 bg-gray-700 rounded" />
225
+ </div>
226
+ }
227
+ >
228
+ <Show
229
+ when={song()}
230
+ fallback={
231
+ <div class="flex items-center p-3 bg-red-900 bg-opacity-20 border border-red-500 border-opacity-30">
232
+ <div class="w-12 h-12 bg-red-800 mr-4 flex items-center justify-center">
233
+ <svg
234
+ class="w-6 h-6 text-red-400"
235
+ fill="none"
236
+ stroke="currentColor"
237
+ viewBox="0 0 24 24"
238
+ >
239
+ <path
240
+ stroke-linecap="round"
241
+ stroke-linejoin="round"
242
+ stroke-width="2"
243
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 16.5c-.77.833.192 2.5 1.732 2.5z"
244
+ />
245
+ </svg>
246
+ </div>
247
+ <div class="flex-1">
248
+ <div class="text-red-400 font-medium">song not found</div>
249
+ <div class="text-red-300 text-sm">id: {props.songId}</div>
250
+ </div>
251
+ </div>
252
+ }
253
+ >
254
+ {(songData) => {
255
+ const relativeTime = createRelativeTimeSignal(songData().createdAt);
256
+
257
+ // calc progress percentage for background fill
258
+ const getProgressPercentage = () => {
259
+ const currentSong = audioState.currentSong();
260
+ if (currentSong?.id === songData().id) {
261
+ // this song is loaded - use live currentTime
262
+ const duration = audioState.duration();
263
+ const currentTime = audioState.currentTime();
264
+ return duration > 0 ? (currentTime / duration) * 100 : 0;
265
+ }
266
+ // not the current song - use saved position if any
267
+ const savedPos =
268
+ audioState.songPlaybackPositions().get(songData().id) ?? 0;
269
+ const dur = songData().duration ?? 0;
270
+ return savedPos > 0 && dur > 0 ? (savedPos / dur) * 100 : 0;
271
+ };
272
+
273
+ // show the progress fill whenever the song is playing or has saved
274
+ // progress past 5%. positions >=95% count as a complete listen and
275
+ // stay fully filled (next play restarts from the beginning)
276
+ const showProgressFill = () => {
277
+ if (isCurrentlyPlaying()) return true;
278
+ return getProgressPercentage() > 5;
279
+ };
280
+
281
+ return (
282
+ <div
283
+ data-testid="song-row"
284
+ class={`group relative flex items-center p-3 group-hover:bg-opacity-70 hover:bg-magenta-500 transition-all duration-200 overflow-hidden ${
285
+ isCurrentlyPlaying() || isCurrentlySelected()
286
+ ? "bg-black"
287
+ : draggedOver()
288
+ ? "border border-magenta-400 border-dashed"
289
+ : isDragging()
290
+ ? "border border-gray-500"
291
+ : "border border-transparent"
292
+ }`}
293
+ draggable={!isSeekBarActive()}
294
+ onDragStart={handleDragStart}
295
+ onDragEnd={handleDragEnd}
296
+ onDragOver={handleDragOver}
297
+ onDragLeave={handleDragLeave}
298
+ onDrop={handleDrop}
299
+ onMouseEnter={() => !isMobile() && setIsHovered(true)}
300
+ onMouseLeave={() => !isMobile() && setIsHovered(false)}
301
+ onClick={isMobile() ? handlePlayPause : undefined}
302
+ onDblClick={isMobile() ? undefined : handlePlayPause}
303
+ onContextMenu={(e) => {
304
+ e.preventDefault();
305
+ handleEditSong();
306
+ }}
307
+ style={{ "-webkit-tap-highlight-color": "transparent" }}
308
+ >
309
+ {/* time progress background */}
310
+ <div
311
+ class="absolute inset-0 transition-all duration-200"
312
+ style={{
313
+ background: showProgressFill()
314
+ ? `linear-gradient(to right, rgba(236, 72, 153, ${isCurrentlyPlaying() ? "0.5" : "0.3"}) ${getProgressPercentage()}%, transparent ${getProgressPercentage()}%)`
315
+ : isCurrentlySelected()
316
+ ? "rgba(236, 72, 153, 0.3)"
317
+ : draggedOver()
318
+ ? "rgba(220, 38, 127, 0.2)"
319
+ : isDragging()
320
+ ? "rgba(107, 114, 128, 0.3)"
321
+ : "transparent",
322
+ "pointer-events": "none",
323
+ }}
324
+ />
325
+
326
+ {/* content overlay */}
327
+ <div class="relative flex items-center w-full">
328
+ {/* song index / album art / play button */}
329
+ <div class="relative w-12 h-12 mr-4 flex-shrink-0 bg-black">
330
+ {/* song index */}
331
+ <div class="absolute inset-0 flex justify-center items-center font-mono group-hover:text-transparent">
332
+ <span class="bg-black">
333
+ {props.index.toString().padStart(3, "0")}
334
+ </span>
335
+ </div>
336
+
337
+ <Show
338
+ when={songData().imageType}
339
+ fallback={
340
+ <div class="w-12 h-12 bg-transparent flex items-center justify-center" />
341
+ }
342
+ >
343
+ {(() => {
344
+ const imageUrl = getImageUrlForContext(
345
+ songData(),
346
+ "thumbnail"
347
+ );
348
+ return (
349
+ <Show
350
+ when={imageUrl}
351
+ fallback={
352
+ <div class="w-12 h-12 bg-transparent flex items-center justify-center" />
353
+ }
354
+ >
355
+ <img
356
+ src={imageUrl || undefined}
357
+ alt={`${songData().title} album art`}
358
+ class="w-12 h-12 object-cover"
359
+ />
360
+ </Show>
361
+ );
362
+ })()}
363
+ </Show>
364
+
365
+ {/* loading overlay - show when loading or preloading */}
366
+ <Show
367
+ when={
368
+ isCurrentlyLoading() ||
369
+ isPreloading() ||
370
+ isCachingActive()
371
+ }
372
+ >
373
+ <div class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-1">
374
+ <div class="relative w-8 h-8">
375
+ {/* circular progress background */}
376
+ <Show
377
+ when={
378
+ downloadProgress() > 0 && downloadProgress() < 100
379
+ }
380
+ >
381
+ <svg
382
+ class="absolute inset-0 w-8 h-8 transform -rotate-90"
383
+ viewBox="0 0 32 32"
384
+ >
385
+ {/* background circle */}
386
+ <circle
387
+ cx="16"
388
+ cy="16"
389
+ r="14"
390
+ stroke="rgba(255, 255, 255, 0.2)"
391
+ stroke-width="2"
392
+ fill="none"
393
+ />
394
+ {/* progress circle */}
395
+ <circle
396
+ cx="16"
397
+ cy="16"
398
+ r="14"
399
+ stroke={isPreloading() ? "#9ca3af" : "#ec4899"}
400
+ stroke-width="2"
401
+ fill="none"
402
+ stroke-linecap="round"
403
+ stroke-dasharray={`${(downloadProgress() / 100) * 87.96} 87.96`}
404
+ class="transition-all duration-300"
405
+ />
406
+ </svg>
407
+ </Show>
408
+ {/* rotating loading icon */}
409
+ <svg
410
+ class={`w-4 h-4 animate-spin absolute inset-0 m-auto ${
411
+ isPreloading()
412
+ ? "text-gray-400"
413
+ : "text-magenta-300"
414
+ }`}
415
+ fill="none"
416
+ stroke="currentColor"
417
+ viewBox="0 0 24 24"
418
+ >
419
+ <path
420
+ stroke-linecap="round"
421
+ stroke-linejoin="round"
422
+ stroke-width="2"
423
+ 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"
424
+ />
425
+ </svg>
426
+ </div>
427
+ </div>
428
+ </Show>
429
+
430
+ {/* play/pause overlay (only shown when not loading or preloading) */}
431
+ <Show
432
+ when={
433
+ !isCurrentlyLoading() &&
434
+ !isPreloading() &&
435
+ !isCachingActive() &&
436
+ isHovered() &&
437
+ !isMobile()
438
+ }
439
+ >
440
+ <button
441
+ onClick={handlePlayPause}
442
+ class="absolute inset-0 bg-transparent flex items-center justify-center transition-opacity hover:bg-opacity-80 text-magenta-300 hover:text-magenta-100"
443
+ >
444
+ <Show
445
+ when={isCurrentlyPlaying()}
446
+ fallback={
447
+ <svg
448
+ class="w-5 h-5"
449
+ fill="currentColor"
450
+ viewBox="0 0 24 24"
451
+ >
452
+ <path d="M8 5v14l11-7z" />
453
+ </svg>
454
+ }
455
+ >
456
+ <svg
457
+ class="w-5 h-5"
458
+ fill="currentColor"
459
+ viewBox="0 0 24 24"
460
+ >
461
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
462
+ </svg>
463
+ </Show>
464
+ </button>
465
+ </Show>
466
+ </div>
467
+
468
+ {/* song info */}
469
+ <div class="flex-1 min-w-0 text-lg">
470
+ <div
471
+ class={`break-words ${
472
+ isCurrentlyPlaying() || isCurrentlySelected()
473
+ ? "text-magenta-200"
474
+ : "text-white"
475
+ }`}
476
+ >
477
+ {songData().title}
478
+ </div>
479
+ <div
480
+ class={`text-sm break-words ${
481
+ isCurrentlyPlaying() || isCurrentlySelected()
482
+ ? "text-magenta-200"
483
+ : "text-white"
484
+ }`}
485
+ >
486
+ {songData().artist}
487
+ {songData().album && <span class="mx-2">•</span>}
488
+ {songData().album}
489
+ </div>
490
+ {/* seek'n destroy! */}
491
+ <Show
492
+ when={isCurrentlySelected() && (isHovered() || isMobile())}
493
+ >
494
+ <div class="text-xs mt-1 text-magenta-200 flex items-center">
495
+ {/* current time with fixed width */}
496
+ <span class="font-mono w-12 text-left tabular-nums">
497
+ {isCurrentlySelected()
498
+ ? formatTime(audioState.currentTime())
499
+ : "0:00"}
500
+ </span>
501
+
502
+ {/* seek bar */}
503
+ <div
504
+ class="flex-1 relative h-4 flex items-center seek-bar-container"
505
+ onMouseDown={() => setIsSeekBarActive(true)}
506
+ onMouseUp={() => setIsSeekBarActive(false)}
507
+ onMouseLeave={() => setIsSeekBarActive(false)}
508
+ >
509
+ <input
510
+ type="range"
511
+ min="0"
512
+ max={songData().duration || 0}
513
+ value={
514
+ isCurrentlySelected() ? audioState.currentTime() : 0
515
+ }
516
+ onInput={(e) => {
517
+ if (isCurrentlySelected()) {
518
+ const seekTime = parseFloat(
519
+ e.currentTarget.value
520
+ );
521
+ seek(seekTime);
522
+ }
523
+ }}
524
+ onMouseDown={() => setIsSeekBarActive(true)}
525
+ onMouseUp={() => setIsSeekBarActive(false)}
526
+ class="w-full h-2 bg-gray-700 rounded-full appearance-none cursor-pointer hover:bg-gray-600 transition-colors seek-slider"
527
+ style={{
528
+ background: isCurrentlySelected()
529
+ ? `linear-gradient(to right, #ec4899 0%, #ec4899 ${(audioState.currentTime() / (songData().duration || 1)) * 100}%, #374151 ${(audioState.currentTime() / (songData().duration || 1)) * 100}%, #374151 100%)`
530
+ : "#374151",
531
+ }}
532
+ />
533
+ </div>
534
+
535
+ {/* total time with fixed width */}
536
+ <Show when={!isMobile()}>
537
+ <span class="font-mono w-12 text-right tabular-nums">
538
+ {formatDuration(songData().duration)}
539
+ </span>
540
+ </Show>
541
+ </div>
542
+ </Show>
543
+ <Show
544
+ when={
545
+ (isCurrentlyPlaying() || isCurrentlySelected()) &&
546
+ !isHovered()
547
+ }
548
+ >
549
+ <div class="text-xs mt-1 text-magenta-200">
550
+ added {relativeTime.signal()}
551
+ </div>
552
+ </Show>
553
+ </div>
554
+ </div>
555
+
556
+ {/* duration */}
557
+ <div
558
+ data-testid="song-duration"
559
+ data-download-state={blobDownloadState() ?? undefined}
560
+ data-sha256={songSha() ?? undefined}
561
+ onClick={
562
+ blobDownloadState() === "error"
563
+ ? () => {
564
+ void fetchSongBlob(song()!);
565
+ }
566
+ : undefined
567
+ }
568
+ class={`text-sm font-mono mr-4 ${blobDownloadState() === "error" ? "cursor-pointer" : ""} ${
569
+ blobDownloadState() === "downloading"
570
+ ? "text-blue-400 animate-pulse" // actively fetching from peer
571
+ : blobDownloadState() === "pending"
572
+ ? "text-gray-500" // queued for prefetch
573
+ : blobDownloadState() === "error"
574
+ ? "text-red-400" // fetch failed
575
+ : isCachingActive() ||
576
+ (isCurrentlyLoading() && blobCached() === false)
577
+ ? "text-gray-400 animate-pulse" // being downloaded/fetched
578
+ : isCurrentlyPlaying() || isCurrentlySelected()
579
+ ? isStandalone() ||
580
+ blobCached() === true ||
581
+ !songSha()
582
+ ? "text-magenta-200 underline underline-offset-2" // playing + cached
583
+ : blobCached() === undefined
584
+ ? "text-gray-500 animate-pulse" // playing, cache state loading
585
+ : "text-magenta-200" // playing, confirmed not cached
586
+ : blobCached() === true ||
587
+ isStandalone() ||
588
+ !songSha()
589
+ ? "text-white underline underline-offset-2" // cached (or no-sha local song)
590
+ : "text-gray-500 group-hover:text-white transition-colors" // not cached or loading
591
+ }`}
592
+ >
593
+ {formatDuration(songData().duration)}
594
+ </div>
595
+
596
+ {/* overlay actions */}
597
+ <Show when={isHovered() && !isMobile()}>
598
+ <div class="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 bg-black bg-opacity-80 px-2 py-1 z-50">
599
+ {/* edit button */}
600
+ <button
601
+ data-testid="btn-edit-song"
602
+ onClick={(e) => {
603
+ e.stopPropagation();
604
+ e.preventDefault();
605
+
606
+ const songData = song();
607
+ if (songData) {
608
+ props.onEdit?.(songData);
609
+ }
610
+ }}
611
+ class="p-1 text-gray-400 hover:text-white transition-colors hover:bg-gray-600"
612
+ title="edit song"
613
+ >
614
+ <svg
615
+ class="w-3 h-3"
616
+ fill="none"
617
+ stroke="currentColor"
618
+ viewBox="0 0 24 24"
619
+ >
620
+ <path
621
+ stroke-linecap="round"
622
+ stroke-linejoin="round"
623
+ stroke-width="2"
624
+ 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"
625
+ />
626
+ </svg>
627
+ </button>
628
+
629
+ {/* delete/remove button */}
630
+ <Show when={props.showRemoveButton}>
631
+ <button
632
+ data-testid="btn-remove-song"
633
+ onClick={(e) => {
634
+ e.stopPropagation();
635
+ e.preventDefault();
636
+ props.onRemove?.(props.songId);
637
+ }}
638
+ class="p-1 text-red-400 hover:text-red-300 transition-colors hover:bg-red-600 hover:bg-opacity-30"
639
+ title="remove from playlist"
640
+ >
641
+ <svg
642
+ class="w-3 h-3"
643
+ fill="none"
644
+ stroke="currentColor"
645
+ viewBox="0 0 24 24"
646
+ >
647
+ <path
648
+ stroke-linecap="round"
649
+ stroke-linejoin="round"
650
+ stroke-width="2"
651
+ 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"
652
+ />
653
+ </svg>
654
+ </button>
655
+ </Show>
656
+
657
+ {/* drag handle */}
658
+ <div
659
+ data-testid="btn-drag-song"
660
+ class={`p-1 text-gray-400 transition-colors cursor-grab ${
661
+ isDragging()
662
+ ? "cursor-grabbing text-magenta-400"
663
+ : "hover:text-gray-300"
664
+ }`}
665
+ title="drag to reorder"
666
+ >
667
+ <svg
668
+ class="w-3 h-3"
669
+ fill="none"
670
+ stroke="currentColor"
671
+ viewBox="0 0 24 24"
672
+ >
673
+ <path
674
+ stroke-linecap="round"
675
+ stroke-linejoin="round"
676
+ stroke-width="2"
677
+ d="M4 6h16M4 12h16M4 18h16"
678
+ />
679
+ </svg>
680
+ </div>
681
+ </div>
682
+ </Show>
683
+ </div>
684
+ );
685
+ }}
686
+ </Show>
687
+ </Show>
688
+ );
689
+ }