@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,494 @@
1
+ import { Show, createEffect, createSignal } from "solid-js";
2
+
3
+ import {
4
+ PlaylistzProvider,
5
+ usePlaylistzManager,
6
+ usePlaylistzSongs,
7
+ usePlaylistzUI,
8
+ usePlaylistzDragDrop,
9
+ usePlaylistzImageModal,
10
+ } from "../context/PlaylistzContext.js";
11
+
12
+ import { PlaylistContainer } from "./playlist/index.js";
13
+ import { ShareLinkKnockPanel } from "./ShareLinkKnockPanel.js";
14
+ import { log } from "../utils/log.js";
15
+ function PlaylistzInner() {
16
+ // context hooks
17
+ const playlistManager = usePlaylistzManager();
18
+ const songState = usePlaylistzSongs();
19
+ const uiState = usePlaylistzUI();
20
+ const dragAndDrop = usePlaylistzDragDrop();
21
+ const imageModal = usePlaylistzImageModal();
22
+
23
+ const {
24
+ playlists,
25
+ selectedPlaylist,
26
+ isInitialized,
27
+ error: managerError,
28
+ backgroundImageUrl,
29
+ selectPlaylist,
30
+ } = playlistManager;
31
+
32
+ const { showDeleteConfirm, setShowDeleteConfirm, handleDeletePlaylist } =
33
+ playlistManager;
34
+
35
+ const { editingPlaylist: _editingPlaylist, error: songError } = songState;
36
+
37
+ const { isMobile } = uiState;
38
+
39
+ const {
40
+ isDragOver,
41
+ handleDragEnter,
42
+ handleDragOver,
43
+ handleDragLeave,
44
+ handleFileDrop,
45
+ processFileImport,
46
+ setIsDragOver,
47
+ error: dragError,
48
+ } = dragAndDrop;
49
+
50
+ const {
51
+ showImageModal,
52
+ closeImageModal,
53
+ handleNextImage,
54
+ handlePrevImage,
55
+ getCurrentImageUrl,
56
+ getCurrentImageTitle,
57
+ getImageCount,
58
+ getCurrentImageNumber,
59
+ hasMultipleImages,
60
+ } = imageModal;
61
+
62
+ // 1 error 2 rule 'em all!
63
+ const error = () => managerError() || songError() || dragError();
64
+
65
+ // derived bg filter string from selected playlist settings
66
+ const bgFilter = () => {
67
+ const p = selectedPlaylist();
68
+ if (!p) return "blur(3px) contrast(3) brightness(0.4)";
69
+ if (p.bgFilterEnabled === false) return "none";
70
+ const blur = p.bgFilterBlur ?? 3;
71
+ const contrast = p.bgFilterContrast ?? 3;
72
+ const brightness = p.bgFilterBrightness ?? 0.4;
73
+ return `blur(${blur}px) contrast(${contrast}) brightness(${brightness})`;
74
+ };
75
+
76
+ const bgSize = () => selectedPlaylist()?.bgSize ?? "cover";
77
+ const bgPosition = () => selectedPlaylist()?.bgPosition ?? "top";
78
+ const bgRepeat = () => selectedPlaylist()?.bgRepeat ?? "no-repeat";
79
+
80
+ // create a wrapper that provides the necessary options to handleFileDrop
81
+ const handleFileDropWrapper = async (e: DragEvent) => {
82
+ // don't allow dropping songs onto a subscribed (read-only) playlist
83
+ const current = selectedPlaylist();
84
+ if (current?.remoteNodeId && !current?.isForked) return;
85
+ await handleFileDrop(e, {
86
+ selectedPlaylist: selectedPlaylist(),
87
+ playlists: playlists(),
88
+ onPlaylistCreated: () => {
89
+ // hmm, i guess playlist will be automatically added via reactive query...
90
+ },
91
+ onPlaylistSelected: (playlist) => {
92
+ selectPlaylist(playlist);
93
+ },
94
+ });
95
+ };
96
+
97
+ // open a #share/ link once the app has initialized. the shared playlist
98
+ // appears in the docIndex live query; select it + start playback when found.
99
+ // the playlist may not be in the reactive list immediately (doc sync takes a
100
+ // moment), so we track the pending docId and auto-select it reactively.
101
+ let shareFragmentHandled = false;
102
+ const [pendingShareDocId, setPendingShareDocId] = createSignal<string | null>(
103
+ null
104
+ );
105
+ const [shareKnockRequired, setShareKnockRequired] = createSignal<{
106
+ ownerNodeId: string;
107
+ docId: string;
108
+ title?: string;
109
+ ownerName?: string;
110
+ } | null>(null);
111
+
112
+ createEffect(() => {
113
+ if (!isInitialized() || shareFragmentHandled) return;
114
+ if (!window.location.hash.startsWith("#share/")) return;
115
+ shareFragmentHandled = true;
116
+ void (async () => {
117
+ try {
118
+ const { handleShareFragment } = await import(
119
+ "../services/sharingService.js"
120
+ );
121
+ const result = await handleShareFragment();
122
+ if (result?.status === "synced") {
123
+ const found = playlists().find((p) => p.id === result.docId);
124
+ if (found) {
125
+ selectPlaylist(found);
126
+ // start playback if nothing is currently playing
127
+ const { playPlaylist, audioState } = await import(
128
+ "../services/audioService.js"
129
+ );
130
+ if (!audioState.isPlaying()) void playPlaylist(found);
131
+ } else {
132
+ // playlist not synced yet - watch for it reactively
133
+ setPendingShareDocId(result.docId);
134
+ }
135
+ } else if (result?.status === "knock_required") {
136
+ setShareKnockRequired(result);
137
+ }
138
+ } catch (err) {
139
+ log.warn("share.fragment", "share link open failed:", err);
140
+ }
141
+ })();
142
+ });
143
+
144
+ // once the pending share playlist appears in the list, select + play it
145
+ createEffect(() => {
146
+ const docId = pendingShareDocId();
147
+ if (!docId) return;
148
+ const found = playlists().find((p) => p.id === docId);
149
+ if (!found) return;
150
+ setPendingShareDocId(null);
151
+ selectPlaylist(found);
152
+ void (async () => {
153
+ const { playPlaylist, audioState } = await import(
154
+ "../services/audioService.js"
155
+ );
156
+ if (!audioState.isPlaying()) void playPlaylist(found);
157
+ })();
158
+ });
159
+
160
+ // resume p2p on boot for users who have already enabled it
161
+ let sharingResumed = false;
162
+ createEffect(() => {
163
+ if (!isInitialized() || sharingResumed) return;
164
+ sharingResumed = true;
165
+ void (async () => {
166
+ try {
167
+ const { resumeSharingIfEnabled } = await import(
168
+ "../services/sharingService.js"
169
+ );
170
+ await resumeSharingIfEnabled();
171
+ } catch (err) {
172
+ log.warn("p2p.resume", "p2p resume failed:", err);
173
+ }
174
+ })();
175
+ });
176
+
177
+ // dev/test hook: exposes file import logic without needing a DragEvent.
178
+ // call window.__processFiles([file1, file2, ...]) from playwright tests.
179
+ // must live here (not in dev-hooks.ts) because it needs the live reactive context.
180
+ if (import.meta.env.DEV) {
181
+ (
182
+ window as typeof window & {
183
+ __processFiles?: (files: File[]) => Promise<void>;
184
+ }
185
+ ).__processFiles = (files: File[]) =>
186
+ processFileImport(files, {
187
+ selectedPlaylist: selectedPlaylist(),
188
+ playlists: playlists(),
189
+ onPlaylistSelected: (playlist) => selectPlaylist(playlist),
190
+ });
191
+
192
+ // load all other dev hooks (audio element control, mock blob fetch, etc.)
193
+ void import("../dev-hooks.js");
194
+ }
195
+
196
+ return (
197
+ <div
198
+ data-testid="app-root"
199
+ class="relative bg-black text-white h-screen overflow-hidden"
200
+ onDragEnter={handleDragEnter}
201
+ onDragOver={handleDragOver}
202
+ onDragLeave={handleDragLeave}
203
+ onDrop={handleFileDropWrapper}
204
+ >
205
+ {/* background image cover */}
206
+ <Show when={backgroundImageUrl()}>
207
+ <div
208
+ class="absolute inset-0 bg-no-repeat transition-opacity duration-1000 ease-out"
209
+ style={{
210
+ "background-image": `url(${backgroundImageUrl()})`,
211
+ "background-size": bgSize(),
212
+ "background-position": bgPosition(),
213
+ "background-repeat": bgRepeat(),
214
+ filter: bgFilter(),
215
+ "z-index": "0",
216
+ }}
217
+ />
218
+ <div class="absolute inset-0 bg-black/20" style={{ "z-index": "1" }} />
219
+ </Show>
220
+
221
+ {/* background pattern (when no song playing) */}
222
+ <Show when={!backgroundImageUrl()}>
223
+ <div
224
+ class="absolute inset-0 opacity-5"
225
+ style={{
226
+ "background-image":
227
+ "radial-gradient(circle at 25% 25%, #ff00ff 2px, transparent 2px)",
228
+ "background-size": "50px 50px",
229
+ "z-index": "0",
230
+ }}
231
+ />
232
+ </Show>
233
+
234
+ {/* main app content */}
235
+ <Show
236
+ when={isInitialized()}
237
+ fallback={
238
+ <div class="flex items-center justify-center h-full">
239
+ <div class="text-center">
240
+ <div class="inline-block animate-spin rounded-full h-8 w-8" />
241
+ <p class="text-lg">loading playlistz...</p>
242
+ </div>
243
+ </div>
244
+ }
245
+ >
246
+ {/* visually hidden landmark for e2e/accessibility - always present once app loads */}
247
+ <h1 class="sr-only" data-testid="app-ready">
248
+ playlistz
249
+ </h1>
250
+ {/* knock-gated share link landing: shown before sync happens */}
251
+ <Show when={shareKnockRequired()}>
252
+ <ShareLinkKnockPanel
253
+ ownerNodeId={shareKnockRequired()!.ownerNodeId}
254
+ docId={shareKnockRequired()!.docId}
255
+ title={shareKnockRequired()!.title}
256
+ ownerName={shareKnockRequired()!.ownerName}
257
+ onAccepted={(docId) => {
258
+ setShareKnockRequired(null);
259
+ setPendingShareDocId(docId);
260
+ }}
261
+ onDismiss={() => setShareKnockRequired(null)}
262
+ />
263
+ </Show>
264
+ {/* full-width playlist content */}
265
+ <div class="relative flex h-full min-w-0" style={{ "z-index": "2" }}>
266
+ <div class="flex-1 flex flex-col min-h-0 min-w-0">
267
+ <Show
268
+ when={selectedPlaylist()}
269
+ fallback={
270
+ // no playlist selected (e.g. fresh install with no playlists yet).
271
+ // show a create button since the hamburger isn't available here.
272
+ <EmptyState />
273
+ }
274
+ >
275
+ {(playlist) => <PlaylistContainer playlist={playlist} />}
276
+ </Show>
277
+ </div>
278
+ </div>
279
+ </Show>
280
+
281
+ {/* drag'n'drop overlay */}
282
+ <Show when={isDragOver()}>
283
+ <div
284
+ onClick={() => {
285
+ setIsDragOver(false);
286
+ }}
287
+ class="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50 backdrop-blur-sm"
288
+ >
289
+ <div class="text-center">
290
+ <div class="text-4xl mb-6 font-bold">drop zone</div>
291
+ <h2 class="text-4xl font-light mb-4 text-magenta-400">
292
+ drop your filez here
293
+ </h2>
294
+ <p class="text-xl text-gray-300">
295
+ release to add filez to{" "}
296
+ {selectedPlaylist()?.title || "a new playlist"}
297
+ </p>
298
+ </div>
299
+ </div>
300
+ </Show>
301
+
302
+ {/* error notifications */}
303
+ <Show when={error()}>
304
+ <div class="fixed bottom-4 right-4 z-50 max-w-sm">
305
+ <div class="bg-red-900 bg-opacity-90 border border-red-500 p-4 shadow-lg">
306
+ <div class="text-red-200 text-sm">{error()}</div>
307
+ </div>
308
+ </div>
309
+ </Show>
310
+
311
+ {/* delete confirmation modal */}
312
+ <Show when={showDeleteConfirm()}>
313
+ <div class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
314
+ <div class="bg-gray-900 border border-gray-600 p-6 max-w-md w-full mx-4">
315
+ <h3 class="text-lg font-semibold text-white mb-4">
316
+ delete playlist?
317
+ </h3>
318
+ <p class="text-gray-300 mb-6">
319
+ are you sure you want to delete "{selectedPlaylist()?.title}"?
320
+ this action cannot be undone.
321
+ </p>
322
+ <div class="flex gap-3 justify-end">
323
+ <button
324
+ onClick={() => setShowDeleteConfirm(false)}
325
+ class="px-4 py-2 text-gray-300 hover:text-white bg-gray-700 hover:bg-gray-600 rounded transition-colors"
326
+ >
327
+ cancel
328
+ </button>
329
+ <button
330
+ onClick={handleDeletePlaylist}
331
+ class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
332
+ >
333
+ delete
334
+ </button>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </Show>
339
+
340
+ {/* image modal */}
341
+ <Show when={showImageModal()}>
342
+ <div class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50">
343
+ <button
344
+ onClick={closeImageModal}
345
+ class="absolute top-4 right-4 text-white hover:text-magenta-400 transition-colors z-10 p-2 bg-black bg-opacity-50 rounded"
346
+ title="close (esc)"
347
+ >
348
+ <svg
349
+ class="w-6 h-6"
350
+ fill="none"
351
+ stroke="currentColor"
352
+ viewBox="0 0 24 24"
353
+ >
354
+ <path
355
+ stroke-linecap="round"
356
+ stroke-linejoin="round"
357
+ stroke-width="2"
358
+ d="M6 18L18 6M6 6l12 12"
359
+ />
360
+ </svg>
361
+ </button>
362
+
363
+ <Show when={getCurrentImageUrl()}>
364
+ <div class="relative w-full h-full flex items-center justify-center p-4">
365
+ <img
366
+ src={getCurrentImageUrl()!}
367
+ onClick={handleNextImage}
368
+ onContextMenu={isMobile() ? handlePrevImage : undefined}
369
+ alt={getCurrentImageTitle() || "song image"}
370
+ class="max-w-full max-h-full object-contain"
371
+ />
372
+
373
+ {/* navigation arrows (currently disabled 🤷) */}
374
+ <Show when={hasMultipleImages()}>
375
+ {/*<button
376
+ onClick={handlePrevImage}
377
+ class="absolute left-4 top-1/2 transform -translate-y-1/2 text-white hover:text-magenta-400 transition-colors p-2 bg-black bg-opacity-50 rounded"
378
+ title="previous image (←)"
379
+ >
380
+ <svg
381
+ class="w-8 h-8"
382
+ fill="none"
383
+ stroke="currentColor"
384
+ viewBox="0 0 24 24"
385
+ >
386
+ <path
387
+ stroke-linecap="round"
388
+ stroke-linejoin="round"
389
+ stroke-width="2"
390
+ d="M15 19l-7-7 7-7"
391
+ />
392
+ </svg>
393
+ </button>
394
+ <button
395
+ onClick={handleNextImage}
396
+ class="absolute right-4 top-1/2 transform -translate-y-1/2 text-white hover:text-magenta-400 transition-colors p-2 bg-black bg-opacity-50 rounded"
397
+ title="next image (→)"
398
+ >
399
+ <svg
400
+ class="w-8 h-8"
401
+ fill="none"
402
+ stroke="currentColor"
403
+ viewBox="0 0 24 24"
404
+ >
405
+ <path
406
+ stroke-linecap="round"
407
+ stroke-linejoin="round"
408
+ stroke-width="2"
409
+ d="M9 5l7 7-7 7"
410
+ />
411
+ </svg>
412
+ </button>*/}
413
+
414
+ {/* image counter */}
415
+ <div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-white bg-black bg-opacity-50 px-3 py-1">
416
+ {/* image title */}
417
+ <Show when={getCurrentImageTitle()}>
418
+ {getCurrentImageTitle()}{" "}
419
+ </Show>
420
+ <span class="text-xs">
421
+ {getCurrentImageNumber()}/{getImageCount()}
422
+ </span>
423
+ </div>
424
+ </Show>
425
+ </div>
426
+ </Show>
427
+ </div>
428
+ </Show>
429
+ </div>
430
+ );
431
+ }
432
+
433
+ // shown when no playlist exists or none is selected
434
+ function EmptyState() {
435
+ const { createNewPlaylist, selectPlaylist } = usePlaylistzManager();
436
+ const [creating, setCreating] = createSignal(false);
437
+
438
+ const handleCreate = async () => {
439
+ if (creating()) return;
440
+ setCreating(true);
441
+ try {
442
+ const p = await createNewPlaylist("new playlist");
443
+ if (p) selectPlaylist(p);
444
+ } finally {
445
+ setCreating(false);
446
+ }
447
+ };
448
+
449
+ return (
450
+ <div class="flex items-center justify-center h-full text-gray-400 text-sm">
451
+ <div class="text-center">
452
+ <p class="mb-6 text-gray-500" data-testid="empty-playlists">
453
+ no playlistz yet
454
+ </p>
455
+ <button
456
+ data-testid="btn-new-playlist"
457
+ onClick={handleCreate}
458
+ disabled={creating()}
459
+ class="flex items-center gap-2 px-4 py-2 bg-magenta-500 hover:bg-magenta-600 disabled:opacity-60 text-white text-sm font-medium transition-colors mx-auto"
460
+ >
461
+ <Show
462
+ when={!creating()}
463
+ fallback={
464
+ <div class="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
465
+ }
466
+ >
467
+ <svg
468
+ class="w-4 h-4"
469
+ fill="none"
470
+ stroke="currentColor"
471
+ viewBox="0 0 24 24"
472
+ >
473
+ <path
474
+ stroke-linecap="round"
475
+ stroke-linejoin="round"
476
+ stroke-width="2"
477
+ d="M12 4v16m8-8H4"
478
+ />
479
+ </svg>
480
+ </Show>
481
+ <span>{creating() ? "creating..." : "new playlist"}</span>
482
+ </button>
483
+ </div>
484
+ </div>
485
+ );
486
+ }
487
+
488
+ export function Playlistz() {
489
+ return (
490
+ <PlaylistzProvider>
491
+ <PlaylistzInner />
492
+ </PlaylistzProvider>
493
+ );
494
+ }