@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,713 @@
|
|
|
1
|
+
// inline all-playlists panel. replaces song rows when the hamburger is pressed.
|
|
2
|
+
//
|
|
3
|
+
// - the currently selected playlist is NOT shown (it's in the header above)
|
|
4
|
+
// - each row: thumbnail, title+description marquee, total time, song count,
|
|
5
|
+
// action buttons (edit, share, download zip)
|
|
6
|
+
// - "new playlist" sticky row at the bottom
|
|
7
|
+
// - title/description text wrapped in tight bg-black spans for legibility
|
|
8
|
+
// over the transparent/blurred playlist background
|
|
9
|
+
|
|
10
|
+
import { For, Show, createSignal, onMount } from "solid-js";
|
|
11
|
+
import {
|
|
12
|
+
createRelativeTimeSignal,
|
|
13
|
+
formatDuration,
|
|
14
|
+
} from "../utils/timeUtils.js";
|
|
15
|
+
import { getImageUrlForContext } from "../services/imageService.js";
|
|
16
|
+
import { audioState, playPlaylist } from "../services/audioService.js";
|
|
17
|
+
import { downloadPlaylistAsZip } from "../services/playlistDownloadService.js";
|
|
18
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
19
|
+
import { usePlaylistzManager } from "../context/PlaylistzContext.js";
|
|
20
|
+
import { MarqueeText } from "./MarqueeText.js";
|
|
21
|
+
import { getSongsForPlaylist } from "../services/playlistDocService.js";
|
|
22
|
+
import {
|
|
23
|
+
openShareLink,
|
|
24
|
+
queryPeerPlaylists,
|
|
25
|
+
ensureSharingReady,
|
|
26
|
+
knockOnPeer,
|
|
27
|
+
type PeerPlaylistListing,
|
|
28
|
+
} from "../services/sharingService.js";
|
|
29
|
+
import { decodeShareToken } from "@freqhole/api-client/playlistz";
|
|
30
|
+
import { ShareLinkKnockPanel } from "./ShareLinkKnockPanel.js";
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
// select a different playlist + open edit mode
|
|
35
|
+
onEdit: (p: Playlist) => void;
|
|
36
|
+
// select a different playlist + open share panel
|
|
37
|
+
onShare: (p: Playlist) => void;
|
|
38
|
+
// called when a share link is successfully opened from the search bar
|
|
39
|
+
onPlaylistAdded?: (docId: string) => void;
|
|
40
|
+
// pre-fill search with a peer nodeId and trigger peer browse on open
|
|
41
|
+
initialQuery?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function AllPlaylistsPanel(props: Props) {
|
|
45
|
+
const {
|
|
46
|
+
playlists,
|
|
47
|
+
selectedPlaylist,
|
|
48
|
+
selectPlaylist,
|
|
49
|
+
selectById,
|
|
50
|
+
createNewPlaylist,
|
|
51
|
+
} = usePlaylistzManager();
|
|
52
|
+
|
|
53
|
+
const [isCreating, setIsCreating] = createSignal(false);
|
|
54
|
+
const [allSongs, setAllSongs] = createSignal<Record<string, Song[]>>({});
|
|
55
|
+
const [query, setQuery] = createSignal(props.initialQuery ?? "");
|
|
56
|
+
const [searchStatus, setSearchStatus] = createSignal<string | null>(null);
|
|
57
|
+
const [peerListing, setPeerListing] =
|
|
58
|
+
createSignal<PeerPlaylistListing | null>(null);
|
|
59
|
+
|
|
60
|
+
// knock modal state for knock-gated share links pasted into the search bar
|
|
61
|
+
const [searchKnockRequired, setSearchKnockRequired] = createSignal<{
|
|
62
|
+
ownerNodeId: string;
|
|
63
|
+
docId: string;
|
|
64
|
+
title?: string;
|
|
65
|
+
ownerName?: string;
|
|
66
|
+
} | null>(null);
|
|
67
|
+
|
|
68
|
+
// knock-with-message state (shown when knockRequired)
|
|
69
|
+
const [knockMessage, setKnockMessage] = createSignal("");
|
|
70
|
+
const [isKnocking, setIsKnocking] = createSignal(false);
|
|
71
|
+
const [knockStatus, setKnockStatus] = createSignal<string | null>(null);
|
|
72
|
+
|
|
73
|
+
// detect if a string is a hex iroh node id (64 lowercase hex chars)
|
|
74
|
+
const isNodeId = (s: string) => /^[0-9a-f]{64}$/i.test(s.trim());
|
|
75
|
+
|
|
76
|
+
// detect share links via decodeShareToken
|
|
77
|
+
const isShareLink = (s: string) => decodeShareToken(s.trim()) !== null;
|
|
78
|
+
|
|
79
|
+
// exclude the currently selected playlist - it stays in the header above
|
|
80
|
+
const otherPlaylists = () => {
|
|
81
|
+
const sel = selectedPlaylist();
|
|
82
|
+
const all = sel ? playlists().filter((p) => p.id !== sel.id) : playlists();
|
|
83
|
+
const q = query().trim().toLowerCase();
|
|
84
|
+
// when in peer browse mode or empty query, show all; otherwise filter
|
|
85
|
+
if (!q || peerListing()) return all;
|
|
86
|
+
return all.filter(
|
|
87
|
+
(p) =>
|
|
88
|
+
p.title.toLowerCase().includes(q) ||
|
|
89
|
+
(p.description ?? "").toLowerCase().includes(q)
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleSearchInput = async (value: string) => {
|
|
94
|
+
setQuery(value);
|
|
95
|
+
setSearchStatus(null);
|
|
96
|
+
setPeerListing(null);
|
|
97
|
+
|
|
98
|
+
const trimmed = value.trim();
|
|
99
|
+
if (!trimmed) return;
|
|
100
|
+
|
|
101
|
+
if (isShareLink(trimmed)) {
|
|
102
|
+
setSearchStatus("opening...");
|
|
103
|
+
try {
|
|
104
|
+
const result = await openShareLink(trimmed);
|
|
105
|
+
if (result.status === "knock_required") {
|
|
106
|
+
setSearchStatus(null);
|
|
107
|
+
setQuery("");
|
|
108
|
+
setSearchKnockRequired(result);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
setQuery("");
|
|
112
|
+
setSearchStatus(null);
|
|
113
|
+
selectById(result.docId);
|
|
114
|
+
props.onPlaylistAdded?.(result.docId);
|
|
115
|
+
props.onClose();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setSearchStatus(
|
|
118
|
+
err instanceof Error ? err.message : "could not open share link"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isNodeId(trimmed)) {
|
|
125
|
+
setSearchStatus("connecting to peer...");
|
|
126
|
+
try {
|
|
127
|
+
await ensureSharingReady();
|
|
128
|
+
const listing = await queryPeerPlaylists(trimmed);
|
|
129
|
+
setPeerListing(listing);
|
|
130
|
+
setSearchStatus(null);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
setSearchStatus(
|
|
133
|
+
err instanceof Error ? err.message : "could not reach peer"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
onMount(() => {
|
|
140
|
+
// if a peer nodeId was provided, trigger the peer browse immediately
|
|
141
|
+
if (props.initialQuery) {
|
|
142
|
+
void handleSearchInput(props.initialQuery);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const visible = otherPlaylists();
|
|
146
|
+
void Promise.allSettled(
|
|
147
|
+
visible.map(async (p) => {
|
|
148
|
+
const songs = await getSongsForPlaylist(p.id);
|
|
149
|
+
setAllSongs((prev) => ({ ...prev, [p.id]: songs }));
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const handleSelect = (p: Playlist) => {
|
|
155
|
+
selectPlaylist(p);
|
|
156
|
+
props.onClose();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const handlePlay = (p: Playlist) => {
|
|
160
|
+
selectPlaylist(p);
|
|
161
|
+
props.onClose();
|
|
162
|
+
void playPlaylist(p);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleCreate = async () => {
|
|
166
|
+
if (isCreating()) return;
|
|
167
|
+
setIsCreating(true);
|
|
168
|
+
try {
|
|
169
|
+
const created = await createNewPlaylist("new playlist");
|
|
170
|
+
if (created) {
|
|
171
|
+
selectPlaylist(created);
|
|
172
|
+
props.onClose();
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
setIsCreating(false);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div class="flex flex-col h-full" data-testid="all-playlists-panel">
|
|
181
|
+
{/* knock modal for knock-gated share links pasted into search */}
|
|
182
|
+
<Show when={searchKnockRequired()}>
|
|
183
|
+
<ShareLinkKnockPanel
|
|
184
|
+
ownerNodeId={searchKnockRequired()!.ownerNodeId}
|
|
185
|
+
docId={searchKnockRequired()!.docId}
|
|
186
|
+
title={searchKnockRequired()!.title}
|
|
187
|
+
ownerName={searchKnockRequired()!.ownerName}
|
|
188
|
+
onAccepted={(docId) => {
|
|
189
|
+
setSearchKnockRequired(null);
|
|
190
|
+
selectById(docId);
|
|
191
|
+
props.onPlaylistAdded?.(docId);
|
|
192
|
+
props.onClose();
|
|
193
|
+
}}
|
|
194
|
+
onDismiss={() => setSearchKnockRequired(null)}
|
|
195
|
+
/>
|
|
196
|
+
</Show>
|
|
197
|
+
{/* always-visible search input */}
|
|
198
|
+
<div class="px-3 pt-2 pb-1 flex-shrink-0">
|
|
199
|
+
<input
|
|
200
|
+
data-testid="input-search-playlists"
|
|
201
|
+
type="text"
|
|
202
|
+
value={query()}
|
|
203
|
+
placeholder="search, paste share link, or node id..."
|
|
204
|
+
onInput={(e) => void handleSearchInput(e.currentTarget.value)}
|
|
205
|
+
onKeyDown={(e) => {
|
|
206
|
+
if (e.key === "Escape") {
|
|
207
|
+
setQuery("");
|
|
208
|
+
setPeerListing(null);
|
|
209
|
+
setSearchStatus(null);
|
|
210
|
+
}
|
|
211
|
+
}}
|
|
212
|
+
class="w-full bg-black/60 text-white px-3 py-2 text-xs border border-white/10 hover:border-white/30 focus:border-magenta-500 focus:outline-none placeholder-gray-600 transition-colors"
|
|
213
|
+
/>
|
|
214
|
+
<Show when={searchStatus()}>
|
|
215
|
+
<div class="mt-1 text-xs text-magenta-400 px-1">
|
|
216
|
+
<span class="bg-black/80 px-1">{searchStatus()}</span>
|
|
217
|
+
</div>
|
|
218
|
+
</Show>
|
|
219
|
+
<Show when={peerListing()?.knockRequired}>
|
|
220
|
+
<div class="mt-2 px-3 space-y-1.5">
|
|
221
|
+
<p class="text-xs text-yellow-500 bg-black/80 px-1">
|
|
222
|
+
this peer requires a knock to view their playlistz
|
|
223
|
+
</p>
|
|
224
|
+
<textarea
|
|
225
|
+
data-testid="input-knock-message"
|
|
226
|
+
value={knockMessage()}
|
|
227
|
+
onInput={(e) => setKnockMessage(e.currentTarget.value)}
|
|
228
|
+
placeholder="say who you are and why you're knocking..."
|
|
229
|
+
rows={2}
|
|
230
|
+
class="w-full bg-black/60 text-white px-2 py-1.5 text-xs border border-white/10 focus:border-magenta-500 focus:outline-none placeholder-gray-600 resize-none"
|
|
231
|
+
/>
|
|
232
|
+
<button
|
|
233
|
+
data-testid="btn-send-knock"
|
|
234
|
+
onClick={async () => {
|
|
235
|
+
const nodeId = query().trim();
|
|
236
|
+
if (!nodeId || isKnocking()) return;
|
|
237
|
+
setIsKnocking(true);
|
|
238
|
+
setKnockStatus(null);
|
|
239
|
+
try {
|
|
240
|
+
await ensureSharingReady();
|
|
241
|
+
await knockOnPeer(nodeId, knockMessage() || undefined);
|
|
242
|
+
setKnockStatus("knock sent - waiting for owner to accept");
|
|
243
|
+
} catch (err) {
|
|
244
|
+
setKnockStatus(
|
|
245
|
+
err instanceof Error ? err.message : "knock failed"
|
|
246
|
+
);
|
|
247
|
+
} finally {
|
|
248
|
+
setIsKnocking(false);
|
|
249
|
+
}
|
|
250
|
+
}}
|
|
251
|
+
disabled={isKnocking()}
|
|
252
|
+
class="w-full px-3 py-1.5 bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-white text-xs transition-colors border border-white/10"
|
|
253
|
+
>
|
|
254
|
+
{isKnocking() ? "knocking..." : "send knock"}
|
|
255
|
+
</button>
|
|
256
|
+
<Show when={knockStatus()}>
|
|
257
|
+
<p class="text-xs text-magenta-400 bg-black/80 px-1">
|
|
258
|
+
{knockStatus()}
|
|
259
|
+
</p>
|
|
260
|
+
</Show>
|
|
261
|
+
</div>
|
|
262
|
+
</Show>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div class="flex-1 overflow-y-auto">
|
|
266
|
+
{/* peer browse mode: show remote playlists */}
|
|
267
|
+
<Show
|
|
268
|
+
when={peerListing()}
|
|
269
|
+
fallback={
|
|
270
|
+
<>
|
|
271
|
+
<Show when={otherPlaylists().length > 0}>
|
|
272
|
+
<For each={otherPlaylists()}>
|
|
273
|
+
{(p) => (
|
|
274
|
+
<PlaylistRow
|
|
275
|
+
playlist={p}
|
|
276
|
+
songs={allSongs()[p.id]}
|
|
277
|
+
onSelect={handleSelect}
|
|
278
|
+
onPlay={handlePlay}
|
|
279
|
+
onEdit={props.onEdit}
|
|
280
|
+
onShare={props.onShare}
|
|
281
|
+
onBrowsePeer={(nodeId) => {
|
|
282
|
+
setQuery(nodeId);
|
|
283
|
+
void handleSearchInput(nodeId);
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
)}
|
|
287
|
+
</For>
|
|
288
|
+
</Show>
|
|
289
|
+
</>
|
|
290
|
+
}
|
|
291
|
+
>
|
|
292
|
+
{(listing) => (
|
|
293
|
+
<Show
|
|
294
|
+
when={listing().items.length > 0}
|
|
295
|
+
fallback={
|
|
296
|
+
<div class="px-4 py-3 text-xs text-gray-500">
|
|
297
|
+
<span class="bg-black/80 px-1">
|
|
298
|
+
no playlistz shared by this peer
|
|
299
|
+
</span>
|
|
300
|
+
</div>
|
|
301
|
+
}
|
|
302
|
+
>
|
|
303
|
+
<div class="px-3 pt-1 pb-0.5 text-xs text-gray-500">
|
|
304
|
+
<span class="bg-black/80 px-1">
|
|
305
|
+
{listing().name
|
|
306
|
+
? `${listing().name}'s playlistz`
|
|
307
|
+
: "peer's playlistz"}
|
|
308
|
+
</span>
|
|
309
|
+
</div>
|
|
310
|
+
<For each={listing().items}>
|
|
311
|
+
{(item) => (
|
|
312
|
+
<PeerPlaylistRow
|
|
313
|
+
item={item}
|
|
314
|
+
nodeId={listing().nodeId}
|
|
315
|
+
onAdd={async (docId) => {
|
|
316
|
+
selectById(docId);
|
|
317
|
+
props.onPlaylistAdded?.(docId);
|
|
318
|
+
props.onClose();
|
|
319
|
+
}}
|
|
320
|
+
onError={(msg) => setSearchStatus(msg)}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</For>
|
|
324
|
+
</Show>
|
|
325
|
+
)}
|
|
326
|
+
</Show>
|
|
327
|
+
|
|
328
|
+
{/* sticky new-playlist row */}
|
|
329
|
+
<div class="sticky bottom-0">
|
|
330
|
+
<button
|
|
331
|
+
data-testid="btn-new-playlist"
|
|
332
|
+
onClick={handleCreate}
|
|
333
|
+
disabled={isCreating()}
|
|
334
|
+
class="w-full flex items-center gap-3 px-4 py-3 text-gray-500 hover:text-white hover:bg-magenta-500/75 disabled:opacity-50 transition-colors border-t border-white/10 bg-black/40 text-sm"
|
|
335
|
+
>
|
|
336
|
+
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center border border-dashed border-gray-600">
|
|
337
|
+
<Show
|
|
338
|
+
when={!isCreating()}
|
|
339
|
+
fallback={
|
|
340
|
+
<div class="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
341
|
+
}
|
|
342
|
+
>
|
|
343
|
+
<svg
|
|
344
|
+
class="w-4 h-4"
|
|
345
|
+
fill="none"
|
|
346
|
+
stroke="currentColor"
|
|
347
|
+
viewBox="0 0 24 24"
|
|
348
|
+
>
|
|
349
|
+
<path
|
|
350
|
+
stroke-linecap="round"
|
|
351
|
+
stroke-linejoin="round"
|
|
352
|
+
stroke-width="2"
|
|
353
|
+
d="M12 4v16m8-8H4"
|
|
354
|
+
/>
|
|
355
|
+
</svg>
|
|
356
|
+
</Show>
|
|
357
|
+
</div>
|
|
358
|
+
<span class="px-1 py-0.5 bg-black text-white">
|
|
359
|
+
{isCreating() ? "creating..." : "new playlist"}
|
|
360
|
+
</span>
|
|
361
|
+
</button>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function PeerPlaylistRow(props: {
|
|
369
|
+
item: PeerPlaylistListing["items"][number];
|
|
370
|
+
nodeId: string;
|
|
371
|
+
onAdd: (docId: string) => Promise<void>;
|
|
372
|
+
onError: (msg: string) => void;
|
|
373
|
+
}) {
|
|
374
|
+
const [adding, setAdding] = createSignal(false);
|
|
375
|
+
|
|
376
|
+
const handleAdd = async () => {
|
|
377
|
+
if (adding()) return;
|
|
378
|
+
setAdding(true);
|
|
379
|
+
try {
|
|
380
|
+
const token = btoa(
|
|
381
|
+
JSON.stringify({
|
|
382
|
+
v: 1,
|
|
383
|
+
n: props.nodeId,
|
|
384
|
+
d: props.item.docId,
|
|
385
|
+
t: props.item.title,
|
|
386
|
+
})
|
|
387
|
+
)
|
|
388
|
+
.replace(/\+/g, "-")
|
|
389
|
+
.replace(/\//g, "_")
|
|
390
|
+
.replace(/=/g, "");
|
|
391
|
+
const result = await openShareLink(`#share/${token}`);
|
|
392
|
+
if (result.status === "synced") await props.onAdd(result.docId);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
props.onError(
|
|
395
|
+
err instanceof Error ? err.message : "failed to add playlist"
|
|
396
|
+
);
|
|
397
|
+
} finally {
|
|
398
|
+
setAdding(false);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<div class="group flex items-center gap-3 px-4 py-3 hover:bg-magenta-500/75 transition-colors">
|
|
404
|
+
{/* placeholder thumbnail */}
|
|
405
|
+
<div class="flex-shrink-0 w-10 h-10 bg-black/40 flex items-center justify-center">
|
|
406
|
+
<svg width="20" height="20" viewBox="0 0 100 100" fill="none">
|
|
407
|
+
<path d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z" fill="#FF00FF" />
|
|
408
|
+
</svg>
|
|
409
|
+
</div>
|
|
410
|
+
<div class="flex-1 min-w-0">
|
|
411
|
+
<div class="text-sm font-medium text-white truncate">
|
|
412
|
+
<span class="bg-black px-1">{props.item.title}</span>
|
|
413
|
+
</div>
|
|
414
|
+
<div class="text-xs text-gray-500 mt-0.5">
|
|
415
|
+
<span class="bg-black px-1">
|
|
416
|
+
{props.item.songCount === 1
|
|
417
|
+
? "1 song"
|
|
418
|
+
: `${props.item.songCount} songz`}
|
|
419
|
+
</span>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
<button
|
|
423
|
+
class="flex-shrink-0 px-3 py-1 text-xs border border-magenta-500 text-magenta-400 hover:bg-magenta-500/20 disabled:opacity-50 transition-colors"
|
|
424
|
+
onClick={() => void handleAdd()}
|
|
425
|
+
disabled={adding()}
|
|
426
|
+
>
|
|
427
|
+
{adding() ? "adding..." : "add"}
|
|
428
|
+
</button>
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function PlaylistRow(props: {
|
|
434
|
+
playlist: Playlist;
|
|
435
|
+
songs?: Song[];
|
|
436
|
+
onSelect: (p: Playlist) => void;
|
|
437
|
+
onPlay: (p: Playlist) => void;
|
|
438
|
+
onEdit: (p: Playlist) => void;
|
|
439
|
+
onShare: (p: Playlist) => void;
|
|
440
|
+
// called when user clicks the sharer identity pill
|
|
441
|
+
onBrowsePeer?: (nodeId: string) => void;
|
|
442
|
+
}) {
|
|
443
|
+
const isPlaying = () =>
|
|
444
|
+
audioState.isPlaying() &&
|
|
445
|
+
audioState.currentPlaylist()?.id === props.playlist.id;
|
|
446
|
+
|
|
447
|
+
const relativeTime = createRelativeTimeSignal(props.playlist.updatedAt);
|
|
448
|
+
|
|
449
|
+
const songCount = () => {
|
|
450
|
+
const n = props.playlist.songIds?.length ?? 0;
|
|
451
|
+
return n === 1 ? "1 song" : `${n} songz`;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const totalTime = () => {
|
|
455
|
+
const songs = props.songs;
|
|
456
|
+
if (!songs || songs.length === 0) return null;
|
|
457
|
+
const secs = songs.reduce((t, s) => t + (s.duration || 0), 0);
|
|
458
|
+
if (secs === 0) return null;
|
|
459
|
+
return formatDuration(secs);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const imageUrl = () => getImageUrlForContext(props.playlist, "thumbnail");
|
|
463
|
+
|
|
464
|
+
const [isHovered, setIsHovered] = createSignal(false);
|
|
465
|
+
const [downloading, setDownloading] = createSignal(false);
|
|
466
|
+
|
|
467
|
+
const handleDownload = async (e: MouseEvent) => {
|
|
468
|
+
e.stopPropagation();
|
|
469
|
+
if (downloading()) return;
|
|
470
|
+
setDownloading(true);
|
|
471
|
+
try {
|
|
472
|
+
await downloadPlaylistAsZip(props.playlist, {
|
|
473
|
+
includeMetadata: true,
|
|
474
|
+
includeImages: true,
|
|
475
|
+
generateM3U: true,
|
|
476
|
+
includeHTML: true,
|
|
477
|
+
});
|
|
478
|
+
} finally {
|
|
479
|
+
setDownloading(false);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<div
|
|
485
|
+
class="group relative flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors hover:bg-magenta-500/75"
|
|
486
|
+
onClick={() => props.onSelect(props.playlist)}
|
|
487
|
+
onDblClick={(e) => {
|
|
488
|
+
e.stopPropagation();
|
|
489
|
+
props.onPlay(props.playlist);
|
|
490
|
+
}}
|
|
491
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
492
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
493
|
+
>
|
|
494
|
+
{/* thumbnail */}
|
|
495
|
+
<div class="relative flex-shrink-0 w-10 h-10 overflow-hidden bg-black/40">
|
|
496
|
+
<Show when={isPlaying()}>
|
|
497
|
+
<div
|
|
498
|
+
data-testid="row-playing-indicator"
|
|
499
|
+
class="absolute inset-0 z-10 flex items-center justify-center bg-black/50"
|
|
500
|
+
title="playing"
|
|
501
|
+
>
|
|
502
|
+
<svg
|
|
503
|
+
class="w-4 h-4 text-magenta-400 animate-pulse"
|
|
504
|
+
fill="currentColor"
|
|
505
|
+
viewBox="0 0 20 20"
|
|
506
|
+
>
|
|
507
|
+
<path d="M6.3 4.06a1 1 0 011.02.04l7 4.5a1 1 0 010 1.7l-7 4.5A1 1 0 016 14V5a1 1 0 01.3-.94z" />
|
|
508
|
+
</svg>
|
|
509
|
+
</div>
|
|
510
|
+
</Show>
|
|
511
|
+
<Show
|
|
512
|
+
when={imageUrl()}
|
|
513
|
+
fallback={
|
|
514
|
+
<div class="w-full h-full flex items-center justify-center">
|
|
515
|
+
<svg width="24" height="24" viewBox="0 0 100 100" fill="none">
|
|
516
|
+
<path
|
|
517
|
+
d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
|
|
518
|
+
fill="#FF00FF"
|
|
519
|
+
/>
|
|
520
|
+
</svg>
|
|
521
|
+
</div>
|
|
522
|
+
}
|
|
523
|
+
>
|
|
524
|
+
<img
|
|
525
|
+
src={imageUrl()!}
|
|
526
|
+
alt={props.playlist.title}
|
|
527
|
+
class="w-full h-full object-cover"
|
|
528
|
+
/>
|
|
529
|
+
</Show>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
{/* text block */}
|
|
533
|
+
<div class="flex-1 min-w-0 overflow-hidden">
|
|
534
|
+
<MarqueeText
|
|
535
|
+
text={props.playlist.title}
|
|
536
|
+
isHovering={isHovered}
|
|
537
|
+
class="text-sm font-medium text-white [&>span]:px-1 [&>span]:bg-black"
|
|
538
|
+
/>
|
|
539
|
+
<Show when={props.playlist.description}>
|
|
540
|
+
<MarqueeText
|
|
541
|
+
text={props.playlist.description!}
|
|
542
|
+
isHovering={isHovered}
|
|
543
|
+
class="text-xs text-gray-400 mt-0.5 [&>span]:px-1 [&>span]:bg-black"
|
|
544
|
+
/>
|
|
545
|
+
</Show>
|
|
546
|
+
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
|
|
547
|
+
<span
|
|
548
|
+
data-testid="row-song-count"
|
|
549
|
+
class="text-xs text-gray-500 px-1 bg-black"
|
|
550
|
+
>
|
|
551
|
+
{songCount()}
|
|
552
|
+
</span>
|
|
553
|
+
<Show when={totalTime()}>
|
|
554
|
+
<span class="text-xs text-gray-700 bg-black px-0.5">·</span>
|
|
555
|
+
<span class="text-xs text-gray-500 px-1 bg-black">
|
|
556
|
+
{totalTime()}
|
|
557
|
+
</span>
|
|
558
|
+
</Show>
|
|
559
|
+
<span class="text-xs text-gray-700 bg-black px-0.5">·</span>
|
|
560
|
+
<span class="text-xs text-gray-500 px-1 bg-black">
|
|
561
|
+
{relativeTime.signal()}
|
|
562
|
+
</span>
|
|
563
|
+
<Show when={props.playlist.remoteNodeId}>
|
|
564
|
+
<span class="text-xs text-gray-700 bg-black px-0.5">·</span>
|
|
565
|
+
<button
|
|
566
|
+
data-testid="btn-browse-sharer"
|
|
567
|
+
class="flex items-center gap-0.5 text-xs text-gray-500 px-1 bg-black hover:text-magenta-400 transition-colors"
|
|
568
|
+
title={`browse ${props.playlist.remoteName || props.playlist.remoteNodeId?.slice(0, 16)}'s playlistz`}
|
|
569
|
+
onClick={(e) => {
|
|
570
|
+
e.stopPropagation();
|
|
571
|
+
if (props.playlist.remoteNodeId)
|
|
572
|
+
props.onBrowsePeer?.(props.playlist.remoteNodeId);
|
|
573
|
+
}}
|
|
574
|
+
>
|
|
575
|
+
<Show
|
|
576
|
+
when={props.playlist.remoteAvatarDataUrl}
|
|
577
|
+
fallback={
|
|
578
|
+
<span class="inline-flex items-center justify-center w-3 h-3 bg-magenta-700/60 text-white text-[7px] font-bold rounded-full overflow-hidden">
|
|
579
|
+
{(
|
|
580
|
+
props.playlist.remoteName ||
|
|
581
|
+
props.playlist.remoteNodeId ||
|
|
582
|
+
""
|
|
583
|
+
)
|
|
584
|
+
.slice(0, 1)
|
|
585
|
+
.toUpperCase()}
|
|
586
|
+
</span>
|
|
587
|
+
}
|
|
588
|
+
>
|
|
589
|
+
<img
|
|
590
|
+
src={props.playlist.remoteAvatarDataUrl}
|
|
591
|
+
alt={props.playlist.remoteName || "peer"}
|
|
592
|
+
class="w-3 h-3 rounded-full object-cover"
|
|
593
|
+
/>
|
|
594
|
+
</Show>
|
|
595
|
+
<span>
|
|
596
|
+
{props.playlist.remoteName ||
|
|
597
|
+
props.playlist.remoteNodeId?.slice(0, 8)}
|
|
598
|
+
</span>
|
|
599
|
+
</button>
|
|
600
|
+
</Show>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
|
|
604
|
+
{/* action buttons - fade in on hover, solid black bg for legibility */}
|
|
605
|
+
<div
|
|
606
|
+
class={`flex-shrink-0 flex items-center bg-black transition-opacity ${
|
|
607
|
+
isHovered() ? "opacity-100" : "opacity-0"
|
|
608
|
+
}`}
|
|
609
|
+
>
|
|
610
|
+
<button
|
|
611
|
+
data-testid="btn-play-playlist-row"
|
|
612
|
+
class="p-3 text-gray-500 hover:text-magenta-400 transition-colors"
|
|
613
|
+
title={`play ${props.playlist.title}`}
|
|
614
|
+
onClick={(e) => {
|
|
615
|
+
e.stopPropagation();
|
|
616
|
+
props.onPlay(props.playlist);
|
|
617
|
+
}}
|
|
618
|
+
>
|
|
619
|
+
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
620
|
+
<path d="M6.3 4.06a1 1 0 011.02.04l7 4.5a1 1 0 010 1.7l-7 4.5A1 1 0 016 14V5a1 1 0 01.3-.94z" />
|
|
621
|
+
</svg>
|
|
622
|
+
</button>
|
|
623
|
+
<button
|
|
624
|
+
data-testid="btn-edit-playlist-row"
|
|
625
|
+
class="p-3 text-gray-500 hover:text-white transition-colors"
|
|
626
|
+
title="edit playlist"
|
|
627
|
+
onClick={(e) => {
|
|
628
|
+
e.stopPropagation();
|
|
629
|
+
props.onEdit(props.playlist);
|
|
630
|
+
}}
|
|
631
|
+
>
|
|
632
|
+
<svg
|
|
633
|
+
class="w-4 h-4"
|
|
634
|
+
fill="none"
|
|
635
|
+
stroke="currentColor"
|
|
636
|
+
viewBox="0 0 24 24"
|
|
637
|
+
>
|
|
638
|
+
<path
|
|
639
|
+
stroke-linecap="round"
|
|
640
|
+
stroke-linejoin="round"
|
|
641
|
+
stroke-width="2"
|
|
642
|
+
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"
|
|
643
|
+
/>
|
|
644
|
+
</svg>
|
|
645
|
+
</button>
|
|
646
|
+
<button
|
|
647
|
+
data-testid="btn-share-playlist-row"
|
|
648
|
+
class="p-3 text-gray-500 hover:text-magenta-400 transition-colors"
|
|
649
|
+
title="share playlist"
|
|
650
|
+
onClick={(e) => {
|
|
651
|
+
e.stopPropagation();
|
|
652
|
+
props.onShare(props.playlist);
|
|
653
|
+
}}
|
|
654
|
+
>
|
|
655
|
+
<svg
|
|
656
|
+
class="w-4 h-4"
|
|
657
|
+
fill="none"
|
|
658
|
+
stroke="currentColor"
|
|
659
|
+
viewBox="0 0 24 24"
|
|
660
|
+
>
|
|
661
|
+
<line x1="7" y1="11.5" x2="17" y2="5.5" stroke-width="1.5" />
|
|
662
|
+
<line x1="7" y1="12.5" x2="17" y2="18.5" stroke-width="1.5" />
|
|
663
|
+
<circle cx="5" cy="12" r="2.5" stroke-width="1.5" />
|
|
664
|
+
<circle cx="19" cy="5" r="2.5" stroke-width="1.5" />
|
|
665
|
+
<circle cx="19" cy="19" r="2.5" stroke-width="1.5" />
|
|
666
|
+
</svg>
|
|
667
|
+
</button>
|
|
668
|
+
<Show when={window.location.protocol !== "file:"}>
|
|
669
|
+
<button
|
|
670
|
+
data-testid="btn-download-zip-row"
|
|
671
|
+
class="p-3 text-gray-500 hover:text-green-400 transition-colors disabled:opacity-40"
|
|
672
|
+
title="download playlist as zip"
|
|
673
|
+
disabled={downloading()}
|
|
674
|
+
onClick={handleDownload}
|
|
675
|
+
>
|
|
676
|
+
<Show
|
|
677
|
+
when={!downloading()}
|
|
678
|
+
fallback={
|
|
679
|
+
<svg
|
|
680
|
+
class="w-4 h-4 animate-spin"
|
|
681
|
+
fill="none"
|
|
682
|
+
stroke="currentColor"
|
|
683
|
+
viewBox="0 0 24 24"
|
|
684
|
+
>
|
|
685
|
+
<path
|
|
686
|
+
stroke-linecap="round"
|
|
687
|
+
stroke-linejoin="round"
|
|
688
|
+
stroke-width="2"
|
|
689
|
+
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"
|
|
690
|
+
/>
|
|
691
|
+
</svg>
|
|
692
|
+
}
|
|
693
|
+
>
|
|
694
|
+
<svg
|
|
695
|
+
class="w-4 h-4"
|
|
696
|
+
fill="none"
|
|
697
|
+
stroke="currentColor"
|
|
698
|
+
viewBox="0 0 24 24"
|
|
699
|
+
>
|
|
700
|
+
<path
|
|
701
|
+
stroke-linecap="round"
|
|
702
|
+
stroke-linejoin="round"
|
|
703
|
+
stroke-width="2"
|
|
704
|
+
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
705
|
+
/>
|
|
706
|
+
</svg>
|
|
707
|
+
</Show>
|
|
708
|
+
</button>
|
|
709
|
+
</Show>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
);
|
|
713
|
+
}
|