@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.
- package/.changeset/config.json +11 -0
- package/.changeset/nice-wolves-thank.md +5 -0
- package/.freqhole-versions.json +4 -0
- package/.github/copilot-instructions.md +201 -0
- package/.github/workflows/changesets.yml +50 -0
- package/.github/workflows/npm-publish.yml +124 -0
- package/.github/workflows/pr-checks.yml +103 -0
- package/README.md +30 -0
- package/build-component.js +141 -0
- package/build-zip-bundle-lib.js +44 -0
- package/config/playwright.config.ts +47 -0
- package/config/vite.config.ts +44 -0
- package/config/vitest.config.ts +39 -0
- package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
- package/dist/assets/index-CbOXzGiA.js +216 -0
- package/dist/assets/index-CbOXzGiA.js.map +1 -0
- package/dist/assets/index-TvJ6RFpy.css +1 -0
- package/dist/assets/midden-DceCrT_L.js +2 -0
- package/dist/assets/midden-DceCrT_L.js.map +1 -0
- package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
- package/dist/index.html +55 -0
- package/dist/sw.js +134 -0
- package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
- package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
- package/docs/E2E_TESTID_PLAN.md +234 -0
- package/docs/IROH_P2P_PLAN.md +302 -0
- package/docs/ROADMAP.md +695 -0
- package/docs/TODO.md +167 -0
- package/docs/bundle-embedding-plan.md +134 -0
- package/docs/standalone-refactor.md +184 -0
- package/e2e/all-playlists.spec.ts +220 -0
- package/e2e/audio-player.spec.ts +226 -0
- package/e2e/collaborative-features.spec.ts +229 -0
- package/e2e/contexts.ts +238 -0
- package/e2e/edit-panel.spec.ts +87 -0
- package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
- package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
- package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
- package/e2e/fixtures/chord-stack-3s.wav +0 -0
- package/e2e/fixtures/cover-anim.gif +0 -0
- package/e2e/fixtures/cover-blue.png +0 -0
- package/e2e/fixtures/cover-checkers.png +0 -0
- package/e2e/fixtures/cover-gradient.jpg +0 -0
- package/e2e/fixtures/cover-mono.gif +0 -0
- package/e2e/fixtures/cover-noise.png +0 -0
- package/e2e/fixtures/cover-plasma.webp +0 -0
- package/e2e/fixtures/cover-portrait.jpg +0 -0
- package/e2e/fixtures/cover-red.png +0 -0
- package/e2e/fixtures/cover-thumb.jpg +0 -0
- package/e2e/fixtures/cover-wide.webp +0 -0
- package/e2e/fixtures/generate.mjs +257 -0
- package/e2e/fixtures/long-drone-90s.mp3 +0 -0
- package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
- package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
- package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
- package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
- package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
- package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
- package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
- package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
- package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
- package/e2e/fixtures/tone-220hz-10s.wav +0 -0
- package/e2e/fixtures/tone-440hz-2s.wav +0 -0
- package/e2e/fixtures/tone-880hz-5s.wav +0 -0
- package/e2e/fixtures/tone-stereo-3s.wav +0 -0
- package/e2e/fixtures/user-provided/README.md +1 -0
- package/e2e/helpers/app.ts +143 -0
- package/e2e/helpers/hooks.ts +133 -0
- package/e2e/helpers/index.ts +12 -0
- package/e2e/helpers/media.ts +125 -0
- package/e2e/helpers.ts +10 -0
- package/e2e/p2p-collaboration.spec.ts +356 -0
- package/e2e/p2p-multi-peer.spec.ts +723 -0
- package/e2e/p2p-states.spec.ts +302 -0
- package/e2e/playback.spec.ts +56 -0
- package/e2e/playlist-crud.spec.ts +126 -0
- package/e2e/share-link-autoplay.spec.ts +129 -0
- package/e2e/sharing-access.spec.ts +205 -0
- package/e2e/sharing.spec.ts +195 -0
- package/e2e/song-cache-state.spec.ts +202 -0
- package/e2e/zip-bundle.spec.ts +855 -0
- package/eslint.config.js +114 -0
- package/index.html +54 -0
- package/package.json +119 -0
- package/public/sw.js +134 -0
- package/scripts/use-local.mjs +37 -0
- package/scripts/use-published.mjs +37 -0
- package/src/App.tsx +9 -0
- package/src/cli/check.ts +164 -0
- package/src/cli/generate.ts +184 -0
- package/src/cli/http.ts +88 -0
- package/src/cli/index.ts +65 -0
- package/src/cli/init.ts +18 -0
- package/src/components/AllPlaylistsPanel.tsx +713 -0
- package/src/components/AudioPlayer.tsx +122 -0
- package/src/components/MarqueeText.tsx +101 -0
- package/src/components/PlaylistCoverModal.tsx +519 -0
- package/src/components/PlaylistEditPanel.tsx +803 -0
- package/src/components/PlaylistSharePanel.tsx +1020 -0
- package/src/components/ShareLinkKnockPanel.tsx +144 -0
- package/src/components/SharePanel.tsx +584 -0
- package/src/components/SongEditModal.tsx +453 -0
- package/src/components/SongEditPanel.tsx +578 -0
- package/src/components/SongRow.tsx +689 -0
- package/src/components/index.tsx +494 -0
- package/src/components/playlist/index.tsx +1203 -0
- package/src/context/PlaylistzContext.tsx +74 -0
- package/src/dev-hooks.ts +35 -0
- package/src/hooks/createDocIndexQuery.ts +53 -0
- package/src/hooks/createDocStore.test.ts +303 -0
- package/src/hooks/createDocStore.ts +90 -0
- package/src/hooks/useDragAndDrop.test.ts +474 -0
- package/src/hooks/useDragAndDrop.ts +400 -0
- package/src/hooks/useImageModal.test.ts +174 -0
- package/src/hooks/useImageModal.ts +201 -0
- package/src/hooks/usePlaylistManager.test.ts +453 -0
- package/src/hooks/usePlaylistManager.ts +685 -0
- package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
- package/src/hooks/usePlaylistsQuery.ts +44 -0
- package/src/hooks/useSongState.test.ts +236 -0
- package/src/hooks/useSongState.ts +114 -0
- package/src/hooks/useUIState.ts +71 -0
- package/src/index.tsx +18 -0
- package/src/services/audioService.dev.ts +22 -0
- package/src/services/audioService.test.ts +1226 -0
- package/src/services/audioService.ts +1395 -0
- package/src/services/automergeRepo.test.ts +269 -0
- package/src/services/automergeRepo.ts +226 -0
- package/src/services/blobTransferService.dev.ts +119 -0
- package/src/services/blobTransferService.test.ts +441 -0
- package/src/services/blobTransferService.ts +702 -0
- package/src/services/docIndexService.test.ts +179 -0
- package/src/services/docIndexService.ts +118 -0
- package/src/services/fileProcessingService.test.ts +554 -0
- package/src/services/fileProcessingService.ts +239 -0
- package/src/services/imageService.test.ts +701 -0
- package/src/services/imageService.ts +365 -0
- package/src/services/indexedDBService.integration.test.ts +104 -0
- package/src/services/indexedDBService.test.ts +202 -0
- package/src/services/indexedDBService.ts +436 -0
- package/src/services/offlineService.test.ts +661 -0
- package/src/services/offlineService.ts +382 -0
- package/src/services/p2pService.test.ts +305 -0
- package/src/services/p2pService.ts +344 -0
- package/src/services/playlistDocService.test.ts +448 -0
- package/src/services/playlistDocService.ts +707 -0
- package/src/services/playlistDownloadService.test.ts +674 -0
- package/src/services/playlistDownloadService.ts +389 -0
- package/src/services/sharingService.test.ts +812 -0
- package/src/services/sharingService.ts +1073 -0
- package/src/services/sharingState.ts +161 -0
- package/src/services/songReactivity.test.ts +620 -0
- package/src/services/songReactivity.ts +145 -0
- package/src/services/standaloneService.test.ts +1025 -0
- package/src/services/standaloneService.ts +588 -0
- package/src/services/streamingAudioService.test.ts +275 -0
- package/src/services/streamingAudioService.ts +166 -0
- package/src/styles.css +428 -0
- package/src/test-setup.ts +547 -0
- package/src/types/global.d.ts +40 -0
- package/src/types/playlist.ts +99 -0
- package/src/utils/hashUtils.ts +41 -0
- package/src/utils/log.ts +97 -0
- package/src/utils/m3u.test.ts +172 -0
- package/src/utils/m3u.ts +136 -0
- package/src/utils/mockData.ts +166 -0
- package/src/utils/standaloneTemplates.test.ts +175 -0
- package/src/utils/standaloneTemplates.ts +83 -0
- package/src/utils/swTemplate.ts +84 -0
- package/src/utils/timeUtils.ts +166 -0
- package/src/utils/typeGuards.ts +171 -0
- package/src/web-component.tsx +98 -0
- package/src/zip-bundle/index.ts +7 -0
- package/src/zip-bundle/m3u.ts +45 -0
- package/src/zip-bundle/types.ts +50 -0
- package/src/zip-bundle/utils.ts +33 -0
- package/src/zip-bundle/zipBuilder.ts +309 -0
- package/tailwind.config.js +55 -0
- 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
|
+
}
|