@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,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
|
+
}
|