@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,578 @@
|
|
|
1
|
+
import { createSignal, Show, onMount, onCleanup } from "solid-js";
|
|
2
|
+
import {
|
|
3
|
+
processPlaylistCover,
|
|
4
|
+
validateImageFile,
|
|
5
|
+
createImageUrlFromData,
|
|
6
|
+
getImageUrlForContext,
|
|
7
|
+
} from "../services/imageService.js";
|
|
8
|
+
import type { Song } from "../types/playlist.js";
|
|
9
|
+
import { usePlaylistzManager } from "../context/PlaylistzContext.jsx";
|
|
10
|
+
import { formatDuration } from "../utils/timeUtils.js";
|
|
11
|
+
|
|
12
|
+
interface SongEditPanelProps {
|
|
13
|
+
song: Song;
|
|
14
|
+
index: number;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onSave: (updatedSong: Song) => void;
|
|
17
|
+
prevSong?: Song;
|
|
18
|
+
nextSong?: Song;
|
|
19
|
+
onNavigate?: (song: Song) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SongEditPanel(props: SongEditPanelProps) {
|
|
23
|
+
const [title, setTitle] = createSignal("");
|
|
24
|
+
const [artist, setArtist] = createSignal("");
|
|
25
|
+
const [album, setAlbum] = createSignal("");
|
|
26
|
+
const [imageData, setImageData] = createSignal<ArrayBuffer | undefined>();
|
|
27
|
+
const [thumbnailData, setThumbnailData] = createSignal<
|
|
28
|
+
ArrayBuffer | undefined
|
|
29
|
+
>();
|
|
30
|
+
const [imageType, setImageType] = createSignal<string | undefined>();
|
|
31
|
+
const [imageUrl, setImageUrl] = createSignal<string | undefined>();
|
|
32
|
+
const [isLoading, setIsLoading] = createSignal(false);
|
|
33
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
|
|
34
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const playlistManager = usePlaylistzManager();
|
|
37
|
+
const { handleRemoveSong } = playlistManager;
|
|
38
|
+
|
|
39
|
+
// initialise (or reset) form state from the song's stored values
|
|
40
|
+
const initFromSong = () => {
|
|
41
|
+
setTitle(props.song.title);
|
|
42
|
+
setArtist(props.song.artist || "");
|
|
43
|
+
setAlbum(props.song.album || "");
|
|
44
|
+
|
|
45
|
+
setImageData(undefined);
|
|
46
|
+
setThumbnailData(undefined);
|
|
47
|
+
setImageType(undefined);
|
|
48
|
+
setImageUrl(undefined);
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
(props.song.imageData || props.song.thumbnailData) &&
|
|
52
|
+
props.song.imageType
|
|
53
|
+
) {
|
|
54
|
+
setImageData(props.song.imageData);
|
|
55
|
+
setThumbnailData(props.song.thumbnailData);
|
|
56
|
+
setImageType(props.song.imageType);
|
|
57
|
+
const displayData = props.song.imageData || props.song.thumbnailData;
|
|
58
|
+
if (displayData) {
|
|
59
|
+
setImageUrl(createImageUrlFromData(displayData, props.song.imageType));
|
|
60
|
+
}
|
|
61
|
+
} else if (props.song.imageFilePath) {
|
|
62
|
+
setImageType(props.song.imageType);
|
|
63
|
+
setImageUrl(props.song.imageFilePath);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
onMount(initFromSong);
|
|
68
|
+
|
|
69
|
+
// true when the form has unsaved edits. image fields compare by reference -
|
|
70
|
+
// after a save props.song gets the same buffer instances, so this goes clean
|
|
71
|
+
const isDirty = () =>
|
|
72
|
+
title().trim() !== props.song.title ||
|
|
73
|
+
artist().trim() !== (props.song.artist || "") ||
|
|
74
|
+
album().trim() !== (props.song.album || "") ||
|
|
75
|
+
imageData() !== props.song.imageData ||
|
|
76
|
+
imageType() !== props.song.imageType;
|
|
77
|
+
|
|
78
|
+
const navDisabledTitle = "save or reset changes first";
|
|
79
|
+
|
|
80
|
+
// left/right arrow keys navigate between songs (when not typing in a field
|
|
81
|
+
// and there are no pending edits)
|
|
82
|
+
onMount(() => {
|
|
83
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
84
|
+
const target = e.target as HTMLElement | null;
|
|
85
|
+
const tag = target?.tagName;
|
|
86
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (isDirty()) return;
|
|
90
|
+
if (e.key === "ArrowLeft" && props.prevSong) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
props.onNavigate?.(props.prevSong);
|
|
93
|
+
} else if (e.key === "ArrowRight" && props.nextSong) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
props.onNavigate?.(props.nextSong);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
document.addEventListener("keydown", onKeyDown);
|
|
99
|
+
onCleanup(() => document.removeEventListener("keydown", onKeyDown));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const handleImageUpload = async (event: Event) => {
|
|
103
|
+
const input = event.target as HTMLInputElement;
|
|
104
|
+
const file = input.files?.[0];
|
|
105
|
+
if (!file) return;
|
|
106
|
+
|
|
107
|
+
const validation = validateImageFile(file);
|
|
108
|
+
if (!validation.valid) {
|
|
109
|
+
setError(validation.error || "invalid image file");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
setIsLoading(true);
|
|
115
|
+
setError(null);
|
|
116
|
+
|
|
117
|
+
const result = await processPlaylistCover(file);
|
|
118
|
+
if (result.success && result.imageData && result.thumbnailData) {
|
|
119
|
+
const prevUrl = imageUrl();
|
|
120
|
+
if (prevUrl) URL.revokeObjectURL(prevUrl);
|
|
121
|
+
|
|
122
|
+
setImageData(result.imageData);
|
|
123
|
+
setThumbnailData(result.thumbnailData);
|
|
124
|
+
setImageType(file.type);
|
|
125
|
+
setImageUrl(createImageUrlFromData(result.imageData, file.type));
|
|
126
|
+
} else {
|
|
127
|
+
setError(result.error || "failed to process image");
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
setError("error uploading image");
|
|
131
|
+
console.error("image upload error:", err);
|
|
132
|
+
} finally {
|
|
133
|
+
setIsLoading(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleSave = async () => {
|
|
138
|
+
if (!title().trim()) {
|
|
139
|
+
setError("title is required");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
setIsLoading(true);
|
|
145
|
+
setError(null);
|
|
146
|
+
|
|
147
|
+
const updatedSong: Song = {
|
|
148
|
+
...props.song,
|
|
149
|
+
title: title().trim(),
|
|
150
|
+
artist: artist().trim() || "unknown artist",
|
|
151
|
+
album: album().trim() || "unknown album",
|
|
152
|
+
imageData: imageData(),
|
|
153
|
+
thumbnailData: thumbnailData(),
|
|
154
|
+
imageType: imageType(),
|
|
155
|
+
updatedAt: Date.now(),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// onSave handler (handleSongSaved in useSongState) persists to IDB.
|
|
159
|
+
// the panel stays open after saving
|
|
160
|
+
await props.onSave(updatedSong);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
setError("failed to save");
|
|
163
|
+
console.error("save error:", err);
|
|
164
|
+
} finally {
|
|
165
|
+
setIsLoading(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const handleSaveAndNext = async () => {
|
|
170
|
+
await handleSave();
|
|
171
|
+
if (!error() && props.nextSong) {
|
|
172
|
+
props.onNavigate?.(props.nextSong);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleClose = () => {
|
|
177
|
+
const url = imageUrl();
|
|
178
|
+
if (url?.startsWith("blob:")) URL.revokeObjectURL(url);
|
|
179
|
+
setError(null);
|
|
180
|
+
props.onClose();
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handleRemoveImage = () => {
|
|
184
|
+
const url = imageUrl();
|
|
185
|
+
if (url) URL.revokeObjectURL(url);
|
|
186
|
+
setImageData(undefined);
|
|
187
|
+
setThumbnailData(undefined);
|
|
188
|
+
setImageType(undefined);
|
|
189
|
+
setImageUrl(undefined);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// preview thumbnail: shows current imageUrl (form state) or falls back to stored path
|
|
193
|
+
const previewImageUrl = () =>
|
|
194
|
+
imageUrl() ?? getImageUrlForContext(props.song, "thumbnail") ?? undefined;
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
data-testid="song-edit-panel"
|
|
199
|
+
class="bg-black/40 border-none overflow-hidden min-w-0 w-full"
|
|
200
|
+
>
|
|
201
|
+
{/* read-only song row preview - updates live as user edits */}
|
|
202
|
+
<div class="flex items-center gap-2 px-3 py-3 bg-black border-none select-none min-w-0">
|
|
203
|
+
{/* close button */}
|
|
204
|
+
<button
|
|
205
|
+
onClick={handleClose}
|
|
206
|
+
class="p-1 text-gray-400 hover:text-white transition-colors flex-shrink-0"
|
|
207
|
+
title="close"
|
|
208
|
+
>
|
|
209
|
+
<svg
|
|
210
|
+
class="w-4 h-4"
|
|
211
|
+
fill="none"
|
|
212
|
+
stroke="currentColor"
|
|
213
|
+
viewBox="0 0 24 24"
|
|
214
|
+
>
|
|
215
|
+
<path
|
|
216
|
+
stroke-linecap="round"
|
|
217
|
+
stroke-linejoin="round"
|
|
218
|
+
stroke-width="2"
|
|
219
|
+
d="M6 18L18 6M6 6l12 12"
|
|
220
|
+
/>
|
|
221
|
+
</svg>
|
|
222
|
+
</button>
|
|
223
|
+
|
|
224
|
+
{/* thumbnail with index overlay (matches SongRow) */}
|
|
225
|
+
<div class="relative w-10 h-10 flex-shrink-0 bg-black overflow-hidden">
|
|
226
|
+
<Show
|
|
227
|
+
when={previewImageUrl()}
|
|
228
|
+
fallback={
|
|
229
|
+
<div class="w-full h-full flex items-center justify-center">
|
|
230
|
+
<svg
|
|
231
|
+
class="w-5 h-5 text-gray-500"
|
|
232
|
+
fill="none"
|
|
233
|
+
stroke="currentColor"
|
|
234
|
+
viewBox="0 0 24 24"
|
|
235
|
+
>
|
|
236
|
+
<path
|
|
237
|
+
stroke-linecap="round"
|
|
238
|
+
stroke-linejoin="round"
|
|
239
|
+
stroke-width="2"
|
|
240
|
+
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
241
|
+
/>
|
|
242
|
+
</svg>
|
|
243
|
+
</div>
|
|
244
|
+
}
|
|
245
|
+
>
|
|
246
|
+
<img
|
|
247
|
+
src={previewImageUrl()}
|
|
248
|
+
alt={title()}
|
|
249
|
+
class="w-full h-full object-cover"
|
|
250
|
+
/>
|
|
251
|
+
</Show>
|
|
252
|
+
{/* index number on a tight black background, centered on the thumbnail */}
|
|
253
|
+
<div class="absolute inset-0 flex justify-center items-center font-mono text-sm text-gray-300">
|
|
254
|
+
<span class="bg-black">
|
|
255
|
+
{props.index.toString().padStart(3, "0")}
|
|
256
|
+
</span>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* song metadata - live from form state */}
|
|
261
|
+
<div class="min-w-0">
|
|
262
|
+
<div class="text-white text-sm font-medium truncate">
|
|
263
|
+
{title() || "(no title)"}
|
|
264
|
+
</div>
|
|
265
|
+
<div class="text-gray-400 text-xs truncate">
|
|
266
|
+
{artist() || ""}
|
|
267
|
+
{artist() && album() ? " - " : ""}
|
|
268
|
+
{album() || ""}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* duration sits left, spacer pushes nav buttons to the right edge */}
|
|
273
|
+
<span class="text-gray-400 text-sm flex-shrink-0">
|
|
274
|
+
{formatDuration(props.song.duration)}
|
|
275
|
+
</span>
|
|
276
|
+
|
|
277
|
+
<div class="flex-1" />
|
|
278
|
+
|
|
279
|
+
{/* prev / next song navigation - disabled while there are unsaved
|
|
280
|
+
edits so they don't get silently lost */}
|
|
281
|
+
<Show when={props.prevSong}>
|
|
282
|
+
<button
|
|
283
|
+
onClick={() => props.onNavigate?.(props.prevSong!)}
|
|
284
|
+
disabled={isDirty()}
|
|
285
|
+
class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 disabled:text-gray-600 disabled:hover:bg-transparent transition-colors flex-shrink-0"
|
|
286
|
+
title={isDirty() ? navDisabledTitle : "edit previous song"}
|
|
287
|
+
>
|
|
288
|
+
<svg
|
|
289
|
+
class="w-4 h-4"
|
|
290
|
+
fill="none"
|
|
291
|
+
stroke="currentColor"
|
|
292
|
+
viewBox="0 0 24 24"
|
|
293
|
+
>
|
|
294
|
+
<path
|
|
295
|
+
stroke-linecap="round"
|
|
296
|
+
stroke-linejoin="round"
|
|
297
|
+
stroke-width="2"
|
|
298
|
+
d="M15 19l-7-7 7-7"
|
|
299
|
+
/>
|
|
300
|
+
</svg>
|
|
301
|
+
</button>
|
|
302
|
+
</Show>
|
|
303
|
+
<Show when={props.nextSong}>
|
|
304
|
+
<button
|
|
305
|
+
onClick={() => props.onNavigate?.(props.nextSong!)}
|
|
306
|
+
disabled={isDirty()}
|
|
307
|
+
class="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 disabled:text-gray-600 disabled:hover:bg-transparent transition-colors flex-shrink-0"
|
|
308
|
+
title={isDirty() ? navDisabledTitle : "edit next song"}
|
|
309
|
+
>
|
|
310
|
+
<svg
|
|
311
|
+
class="w-4 h-4"
|
|
312
|
+
fill="none"
|
|
313
|
+
stroke="currentColor"
|
|
314
|
+
viewBox="0 0 24 24"
|
|
315
|
+
>
|
|
316
|
+
<path
|
|
317
|
+
stroke-linecap="round"
|
|
318
|
+
stroke-linejoin="round"
|
|
319
|
+
stroke-width="2"
|
|
320
|
+
d="M9 5l7 7-7 7"
|
|
321
|
+
/>
|
|
322
|
+
</svg>
|
|
323
|
+
</button>
|
|
324
|
+
</Show>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
{/* edit form: on sm+ the text fields sit left (clamped to 500px), image
|
|
328
|
+
right. justify-between pushes the columns apart so spare width sits in
|
|
329
|
+
the middle. order utilities keep the image on top for mobile */}
|
|
330
|
+
<div class="p-4 grid grid-cols-1 sm:grid-cols-[minmax(0,500px)_min(40%,24rem)] sm:justify-between gap-4">
|
|
331
|
+
{/* album art + image buttons */}
|
|
332
|
+
<div class="flex flex-col gap-2 sm:order-2">
|
|
333
|
+
{/* image sizes naturally at its own aspect ratio (no gray bars);
|
|
334
|
+
the gray square only shows as the no-image fallback */}
|
|
335
|
+
<Show
|
|
336
|
+
when={imageUrl()}
|
|
337
|
+
fallback={
|
|
338
|
+
<div class="w-full aspect-square bg-gray-700 flex items-center justify-center">
|
|
339
|
+
<svg
|
|
340
|
+
class="w-8 h-8 text-gray-400"
|
|
341
|
+
fill="none"
|
|
342
|
+
stroke="currentColor"
|
|
343
|
+
viewBox="0 0 24 24"
|
|
344
|
+
>
|
|
345
|
+
<path
|
|
346
|
+
stroke-linecap="round"
|
|
347
|
+
stroke-linejoin="round"
|
|
348
|
+
stroke-width="2"
|
|
349
|
+
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
|
|
350
|
+
/>
|
|
351
|
+
</svg>
|
|
352
|
+
</div>
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
<img src={imageUrl()} alt="album art" class="w-full h-auto" />
|
|
356
|
+
</Show>
|
|
357
|
+
<input
|
|
358
|
+
type="file"
|
|
359
|
+
accept="image/*"
|
|
360
|
+
onChange={handleImageUpload}
|
|
361
|
+
disabled={isLoading()}
|
|
362
|
+
class="hidden"
|
|
363
|
+
id="song-image-upload-panel"
|
|
364
|
+
/>
|
|
365
|
+
{/* mt-auto pushes the buttons to the column bottom so both columns
|
|
366
|
+
end at the same height regardless of image aspect ratio */}
|
|
367
|
+
<label
|
|
368
|
+
for="song-image-upload-panel"
|
|
369
|
+
class="mt-auto block w-full px-3 py-1.5 bg-magenta-500 hover:bg-magenta-600 text-white cursor-pointer text-sm font-medium transition-colors text-center"
|
|
370
|
+
>
|
|
371
|
+
choose image
|
|
372
|
+
</label>
|
|
373
|
+
<Show when={imageData()}>
|
|
374
|
+
<button
|
|
375
|
+
onClick={handleRemoveImage}
|
|
376
|
+
disabled={isLoading()}
|
|
377
|
+
class="block w-full px-3 py-1.5 bg-red-700 hover:bg-red-800 text-white text-sm font-medium transition-colors text-center"
|
|
378
|
+
>
|
|
379
|
+
remove image
|
|
380
|
+
</button>
|
|
381
|
+
</Show>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{/* text fields + file info */}
|
|
385
|
+
<div class="flex flex-col gap-3 sm:order-1">
|
|
386
|
+
<div>
|
|
387
|
+
<div class="flex items-center justify-between mb-1">
|
|
388
|
+
<label class="text-xs font-medium text-gray-400">title</label>
|
|
389
|
+
<Show when={title().trim() !== props.song.title}>
|
|
390
|
+
<button
|
|
391
|
+
onClick={() => setTitle(props.song.title)}
|
|
392
|
+
class="text-xs text-gray-500 hover:text-white transition-colors"
|
|
393
|
+
title="reset title"
|
|
394
|
+
>
|
|
395
|
+
reset
|
|
396
|
+
</button>
|
|
397
|
+
</Show>
|
|
398
|
+
</div>
|
|
399
|
+
<input
|
|
400
|
+
type="text"
|
|
401
|
+
value={title()}
|
|
402
|
+
onInput={(e) => setTitle(e.currentTarget.value)}
|
|
403
|
+
disabled={isLoading()}
|
|
404
|
+
class="w-full bg-black text-white px-3 py-2 border border-gray-600 hover:border-gray-400 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors text-sm"
|
|
405
|
+
placeholder="song title"
|
|
406
|
+
/>
|
|
407
|
+
</div>
|
|
408
|
+
<div>
|
|
409
|
+
<div class="flex items-center justify-between mb-1">
|
|
410
|
+
<label class="text-xs font-medium text-gray-400">artist</label>
|
|
411
|
+
<Show when={artist().trim() !== (props.song.artist || "")}>
|
|
412
|
+
<button
|
|
413
|
+
onClick={() => setArtist(props.song.artist || "")}
|
|
414
|
+
class="text-xs text-gray-500 hover:text-white transition-colors"
|
|
415
|
+
title="reset artist"
|
|
416
|
+
>
|
|
417
|
+
reset
|
|
418
|
+
</button>
|
|
419
|
+
</Show>
|
|
420
|
+
</div>
|
|
421
|
+
<input
|
|
422
|
+
type="text"
|
|
423
|
+
value={artist()}
|
|
424
|
+
onInput={(e) => setArtist(e.currentTarget.value)}
|
|
425
|
+
disabled={isLoading()}
|
|
426
|
+
class="w-full bg-black text-white px-3 py-2 border border-gray-600 hover:border-gray-400 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors text-sm"
|
|
427
|
+
placeholder="artist name"
|
|
428
|
+
/>
|
|
429
|
+
</div>
|
|
430
|
+
<div>
|
|
431
|
+
<div class="flex items-center justify-between mb-1">
|
|
432
|
+
<label class="text-xs font-medium text-gray-400">album</label>
|
|
433
|
+
<Show when={album().trim() !== (props.song.album || "")}>
|
|
434
|
+
<button
|
|
435
|
+
onClick={() => setAlbum(props.song.album || "")}
|
|
436
|
+
class="text-xs text-gray-500 hover:text-white transition-colors"
|
|
437
|
+
title="reset album"
|
|
438
|
+
>
|
|
439
|
+
reset
|
|
440
|
+
</button>
|
|
441
|
+
</Show>
|
|
442
|
+
</div>
|
|
443
|
+
<input
|
|
444
|
+
type="text"
|
|
445
|
+
value={album()}
|
|
446
|
+
onInput={(e) => setAlbum(e.currentTarget.value)}
|
|
447
|
+
disabled={isLoading()}
|
|
448
|
+
class="w-full bg-black text-white px-3 py-2 border border-gray-600 hover:border-gray-400 focus:border-magenta-500 focus:ring-1 focus:ring-magenta-500 focus:outline-none transition-colors text-sm"
|
|
449
|
+
placeholder="album name"
|
|
450
|
+
/>
|
|
451
|
+
</div>
|
|
452
|
+
<div class="bg-black p-3 text-xs text-gray-400 space-y-1 mt-auto">
|
|
453
|
+
<div>filename: {props.song.originalFilename || "unknown"}</div>
|
|
454
|
+
<Show when={props.song.fileSize}>
|
|
455
|
+
<div>
|
|
456
|
+
size:{" "}
|
|
457
|
+
{Math.round((props.song.fileSize! / 1024 / 1024) * 100) / 100}{" "}
|
|
458
|
+
mb
|
|
459
|
+
</div>
|
|
460
|
+
</Show>
|
|
461
|
+
<div>duration: {formatDuration(props.song.duration)}</div>
|
|
462
|
+
<Show when={props.song.sha}>
|
|
463
|
+
<div class="break-all">sha: {props.song.sha}</div>
|
|
464
|
+
</Show>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
{/* error - full width */}
|
|
469
|
+
<Show when={error()}>
|
|
470
|
+
<div class="sm:col-span-2 bg-red-900/30 border border-red-500 p-3">
|
|
471
|
+
<div class="text-red-400 text-sm">{error()}</div>
|
|
472
|
+
</div>
|
|
473
|
+
</Show>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
{/* footer: delete on left, cancel+save on right */}
|
|
477
|
+
<Show
|
|
478
|
+
when={showDeleteConfirm()}
|
|
479
|
+
fallback={
|
|
480
|
+
<div class="flex items-center gap-3 px-4 py-3 border-none">
|
|
481
|
+
<button
|
|
482
|
+
onClick={() => setShowDeleteConfirm(true)}
|
|
483
|
+
disabled={isLoading()}
|
|
484
|
+
class="px-3 py-2 bg-red-700 hover:bg-red-800 disabled:bg-red-400 text-white text-sm font-medium transition-colors flex items-center gap-2"
|
|
485
|
+
title="delete song"
|
|
486
|
+
>
|
|
487
|
+
<svg
|
|
488
|
+
class="w-4 h-4 flex-shrink-0"
|
|
489
|
+
fill="none"
|
|
490
|
+
stroke="currentColor"
|
|
491
|
+
viewBox="0 0 24 24"
|
|
492
|
+
>
|
|
493
|
+
<path
|
|
494
|
+
stroke-linecap="round"
|
|
495
|
+
stroke-linejoin="round"
|
|
496
|
+
stroke-width="2"
|
|
497
|
+
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"
|
|
498
|
+
/>
|
|
499
|
+
</svg>
|
|
500
|
+
<span class="hidden sm:inline">delete</span>
|
|
501
|
+
</button>
|
|
502
|
+
<div class="flex-1" />
|
|
503
|
+
<button
|
|
504
|
+
onClick={handleSave}
|
|
505
|
+
disabled={isLoading()}
|
|
506
|
+
class="px-4 py-2 sm:px-6 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white font-medium transition-colors flex items-center gap-2"
|
|
507
|
+
>
|
|
508
|
+
<Show
|
|
509
|
+
when={!isLoading()}
|
|
510
|
+
fallback={
|
|
511
|
+
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
512
|
+
}
|
|
513
|
+
>
|
|
514
|
+
<svg
|
|
515
|
+
class="w-4 h-4"
|
|
516
|
+
fill="none"
|
|
517
|
+
stroke="currentColor"
|
|
518
|
+
viewBox="0 0 24 24"
|
|
519
|
+
>
|
|
520
|
+
<path
|
|
521
|
+
stroke-linecap="round"
|
|
522
|
+
stroke-linejoin="round"
|
|
523
|
+
stroke-width="2"
|
|
524
|
+
d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2zM17 21v-8H7v8M7 3v5h8"
|
|
525
|
+
/>
|
|
526
|
+
</svg>
|
|
527
|
+
</Show>
|
|
528
|
+
<span class="hidden sm:inline">save</span>
|
|
529
|
+
</button>
|
|
530
|
+
<Show when={props.nextSong}>
|
|
531
|
+
<button
|
|
532
|
+
onClick={handleSaveAndNext}
|
|
533
|
+
disabled={isLoading()}
|
|
534
|
+
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-500 text-white font-medium transition-colors flex items-center gap-2"
|
|
535
|
+
title="save and go to next song"
|
|
536
|
+
>
|
|
537
|
+
save
|
|
538
|
+
<svg
|
|
539
|
+
class="w-4 h-4"
|
|
540
|
+
fill="none"
|
|
541
|
+
stroke="currentColor"
|
|
542
|
+
viewBox="0 0 24 24"
|
|
543
|
+
>
|
|
544
|
+
<path
|
|
545
|
+
stroke-linecap="round"
|
|
546
|
+
stroke-linejoin="round"
|
|
547
|
+
stroke-width="2"
|
|
548
|
+
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
|
549
|
+
/>
|
|
550
|
+
</svg>
|
|
551
|
+
</button>
|
|
552
|
+
</Show>
|
|
553
|
+
</div>
|
|
554
|
+
}
|
|
555
|
+
>
|
|
556
|
+
<div class="bg-red-900/30 border-t border-red-500 px-4 py-3 space-y-2">
|
|
557
|
+
<p class="text-white text-sm">delete this song? cannot be undone.</p>
|
|
558
|
+
<div class="flex gap-2">
|
|
559
|
+
<button
|
|
560
|
+
onClick={() => handleRemoveSong(props.song.id, props.onClose)}
|
|
561
|
+
disabled={isLoading()}
|
|
562
|
+
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium transition-colors"
|
|
563
|
+
>
|
|
564
|
+
yes, delete
|
|
565
|
+
</button>
|
|
566
|
+
<button
|
|
567
|
+
onClick={() => setShowDeleteConfirm(false)}
|
|
568
|
+
disabled={isLoading()}
|
|
569
|
+
class="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white text-sm font-medium transition-colors"
|
|
570
|
+
>
|
|
571
|
+
cancel
|
|
572
|
+
</button>
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
</Show>
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|