@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,584 @@
|
|
|
1
|
+
// p2p sharing panel: endpoint setup, node status, share link paste,
|
|
2
|
+
// and the knock inbox. opened from the sidebar header.
|
|
3
|
+
import { createSignal, createEffect, onCleanup, Show, For } from "solid-js";
|
|
4
|
+
import {
|
|
5
|
+
getShareSettings,
|
|
6
|
+
saveShareSettings,
|
|
7
|
+
ensureSharingReady,
|
|
8
|
+
openShareLink,
|
|
9
|
+
getInboundKnocks,
|
|
10
|
+
acceptKnock,
|
|
11
|
+
denyKnock,
|
|
12
|
+
onKnocksChanged,
|
|
13
|
+
queryPeerPlaylists,
|
|
14
|
+
knockOnPeer,
|
|
15
|
+
type ShareSettings,
|
|
16
|
+
type PeerPlaylistListing,
|
|
17
|
+
} from "../services/sharingService.js";
|
|
18
|
+
import {
|
|
19
|
+
getIdentity,
|
|
20
|
+
isLeader,
|
|
21
|
+
onLeadershipChange,
|
|
22
|
+
onIdentityChange,
|
|
23
|
+
} from "../services/p2pService.js";
|
|
24
|
+
import { getIrohAdapter } from "../services/automergeRepo.js";
|
|
25
|
+
import type { KnockRecord } from "../services/indexedDBService.js";
|
|
26
|
+
import type { Playlist } from "../types/playlist.js";
|
|
27
|
+
|
|
28
|
+
interface SharePanelProps {
|
|
29
|
+
isOpen: boolean;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
playlists: Playlist[];
|
|
32
|
+
onPlaylistAdded?: (docId: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function SharePanel(props: SharePanelProps) {
|
|
36
|
+
const [settings, setSettings] = createSignal<ShareSettings>({
|
|
37
|
+
name: "",
|
|
38
|
+
mode: "knock",
|
|
39
|
+
});
|
|
40
|
+
const [nodeId, setNodeId] = createSignal<string>("");
|
|
41
|
+
const [leader, setLeader] = createSignal(false);
|
|
42
|
+
const [p2pEnabled, setP2pEnabled] = createSignal(false);
|
|
43
|
+
const [starting, setStarting] = createSignal(false);
|
|
44
|
+
const [connSummary, setConnSummary] = createSignal({
|
|
45
|
+
connected: 0,
|
|
46
|
+
reconnecting: 0,
|
|
47
|
+
failed: 0,
|
|
48
|
+
});
|
|
49
|
+
const [pasteValue, setPasteValue] = createSignal("");
|
|
50
|
+
const [pasteStatus, setPasteStatus] = createSignal<string | null>(null);
|
|
51
|
+
const [knocks, setKnocks] = createSignal<KnockRecord[]>([]);
|
|
52
|
+
const [grantSelection, setGrantSelection] = createSignal<
|
|
53
|
+
Record<string, Set<string>>
|
|
54
|
+
>({});
|
|
55
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
56
|
+
const [copied, setCopied] = createSignal(false);
|
|
57
|
+
// browse a remote peer
|
|
58
|
+
const [browseNodeId, setBrowseNodeId] = createSignal("");
|
|
59
|
+
const [browseResult, setBrowseResult] =
|
|
60
|
+
createSignal<PeerPlaylistListing | null>(null);
|
|
61
|
+
const [browseStatus, setBrowseStatus] = createSignal<string | null>(null);
|
|
62
|
+
|
|
63
|
+
let unsubKnocks: (() => void) | null = null;
|
|
64
|
+
let unsubLeader: (() => void) | null = null;
|
|
65
|
+
let unsubIdentity: (() => void) | null = null;
|
|
66
|
+
let connTimer: ReturnType<typeof setInterval> | null = null;
|
|
67
|
+
|
|
68
|
+
async function refreshKnocks() {
|
|
69
|
+
setKnocks(await getInboundKnocks());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function refreshConnSummary() {
|
|
73
|
+
try {
|
|
74
|
+
setConnSummary(getIrohAdapter().getConnectionSummary());
|
|
75
|
+
} catch {
|
|
76
|
+
// repo not constructed yet
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
createEffect(() => {
|
|
81
|
+
if (!props.isOpen) return;
|
|
82
|
+
|
|
83
|
+
void (async () => {
|
|
84
|
+
setSettings(await getShareSettings());
|
|
85
|
+
await refreshKnocks();
|
|
86
|
+
const identity = getIdentity();
|
|
87
|
+
if (identity?.node_id) {
|
|
88
|
+
setNodeId(identity.node_id);
|
|
89
|
+
setP2pEnabled(true);
|
|
90
|
+
}
|
|
91
|
+
setLeader(isLeader());
|
|
92
|
+
refreshConnSummary();
|
|
93
|
+
})();
|
|
94
|
+
|
|
95
|
+
unsubKnocks = onKnocksChanged(() => void refreshKnocks());
|
|
96
|
+
unsubLeader = onLeadershipChange((l) => setLeader(l));
|
|
97
|
+
unsubIdentity = onIdentityChange((identity) => {
|
|
98
|
+
if (identity?.node_id) setNodeId(identity.node_id);
|
|
99
|
+
});
|
|
100
|
+
connTimer = setInterval(refreshConnSummary, 3000);
|
|
101
|
+
|
|
102
|
+
onCleanup(() => {
|
|
103
|
+
unsubKnocks?.();
|
|
104
|
+
unsubLeader?.();
|
|
105
|
+
unsubIdentity?.();
|
|
106
|
+
if (connTimer) clearInterval(connTimer);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const handleEnableP2P = async () => {
|
|
111
|
+
setStarting(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
try {
|
|
114
|
+
await ensureSharingReady();
|
|
115
|
+
setP2pEnabled(true);
|
|
116
|
+
const identity = getIdentity();
|
|
117
|
+
if (identity?.node_id) setNodeId(identity.node_id);
|
|
118
|
+
setLeader(isLeader());
|
|
119
|
+
} catch (err) {
|
|
120
|
+
setError(err instanceof Error ? err.message : "failed to start p2p");
|
|
121
|
+
} finally {
|
|
122
|
+
setStarting(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleSaveSettings = async (update: Partial<ShareSettings>) => {
|
|
127
|
+
const next = { ...settings(), ...update };
|
|
128
|
+
setSettings(next);
|
|
129
|
+
await saveShareSettings(next);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleCopyNodeId = async () => {
|
|
133
|
+
try {
|
|
134
|
+
await navigator.clipboard.writeText(nodeId());
|
|
135
|
+
setCopied(true);
|
|
136
|
+
setTimeout(() => setCopied(false), 1500);
|
|
137
|
+
} catch {
|
|
138
|
+
// clipboard unavailable
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleOpenLink = async () => {
|
|
143
|
+
const input = pasteValue().trim();
|
|
144
|
+
if (!input) return;
|
|
145
|
+
setPasteStatus("opening...");
|
|
146
|
+
setError(null);
|
|
147
|
+
try {
|
|
148
|
+
const result = await openShareLink(input);
|
|
149
|
+
if (result.status === "knock_required") {
|
|
150
|
+
setPasteStatus(
|
|
151
|
+
"this playlist requires a knock - the owner has enabled 'knock first' mode"
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
setPasteStatus("playlist added!");
|
|
156
|
+
setPasteValue("");
|
|
157
|
+
props.onPlaylistAdded?.(result.docId);
|
|
158
|
+
setTimeout(() => setPasteStatus(null), 2000);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
setPasteStatus(null);
|
|
161
|
+
setError(
|
|
162
|
+
err instanceof Error ? err.message : "could not open share link"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleBrowsePeer = async () => {
|
|
168
|
+
const target = browseNodeId().trim();
|
|
169
|
+
if (!target) return;
|
|
170
|
+
setBrowseStatus("connecting...");
|
|
171
|
+
setBrowseResult(null);
|
|
172
|
+
setError(null);
|
|
173
|
+
try {
|
|
174
|
+
const listing = await queryPeerPlaylists(target);
|
|
175
|
+
setBrowseResult(listing);
|
|
176
|
+
setBrowseStatus(null);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
setBrowseStatus(null);
|
|
179
|
+
setError(err instanceof Error ? err.message : "could not reach peer");
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handleKnock = async () => {
|
|
184
|
+
const target = browseNodeId().trim();
|
|
185
|
+
if (!target) return;
|
|
186
|
+
setBrowseStatus("knocking...");
|
|
187
|
+
setError(null);
|
|
188
|
+
try {
|
|
189
|
+
const result = await knockOnPeer(target);
|
|
190
|
+
if (result.status === "accepted") {
|
|
191
|
+
setBrowseStatus(`accepted! ${result.docIds.length} playlistz shared`);
|
|
192
|
+
if (result.docIds.length > 0) {
|
|
193
|
+
props.onPlaylistAdded?.(result.docIds[0]!);
|
|
194
|
+
}
|
|
195
|
+
} else if (result.status === "pending") {
|
|
196
|
+
setBrowseStatus("knock sent - waiting for them to accept");
|
|
197
|
+
} else {
|
|
198
|
+
setBrowseStatus("knock denied");
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
setBrowseStatus(null);
|
|
202
|
+
setError(err instanceof Error ? err.message : "knock failed");
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const toggleGrantDoc = (knockId: string, docId: string) => {
|
|
207
|
+
setGrantSelection((prev) => {
|
|
208
|
+
const next = { ...prev };
|
|
209
|
+
const set = new Set(next[knockId] ?? []);
|
|
210
|
+
if (set.has(docId)) {
|
|
211
|
+
set.delete(docId);
|
|
212
|
+
} else {
|
|
213
|
+
set.add(docId);
|
|
214
|
+
}
|
|
215
|
+
next[knockId] = set;
|
|
216
|
+
return next;
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handleAccept = async (knock: KnockRecord) => {
|
|
221
|
+
const selected = grantSelection()[knock.id];
|
|
222
|
+
const docIds =
|
|
223
|
+
selected && selected.size > 0
|
|
224
|
+
? Array.from(selected)
|
|
225
|
+
: props.playlists.map((p) => p.id);
|
|
226
|
+
setError(null);
|
|
227
|
+
try {
|
|
228
|
+
await acceptKnock(knock.id, docIds);
|
|
229
|
+
await refreshKnocks();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
setError(err instanceof Error ? err.message : "accept failed");
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleDeny = async (knock: KnockRecord) => {
|
|
236
|
+
await denyKnock(knock.id);
|
|
237
|
+
await refreshKnocks();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const pendingKnocks = () => knocks().filter((k) => k.status === "pending");
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<Show when={props.isOpen}>
|
|
244
|
+
<div
|
|
245
|
+
class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4"
|
|
246
|
+
onClick={(e) => {
|
|
247
|
+
if (e.target === e.currentTarget) props.onClose();
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
<div class="bg-black border border-magenta-500 w-full max-w-lg max-h-[85vh] overflow-y-auto p-6 font-mono text-white">
|
|
251
|
+
<div class="flex items-center justify-between mb-4">
|
|
252
|
+
<h2 class="text-xl font-bold">
|
|
253
|
+
share<span class="text-magenta-500">z</span>
|
|
254
|
+
</h2>
|
|
255
|
+
<button
|
|
256
|
+
onClick={props.onClose}
|
|
257
|
+
title="close share panel"
|
|
258
|
+
class="text-gray-400 hover:text-white"
|
|
259
|
+
>
|
|
260
|
+
<svg
|
|
261
|
+
class="w-6 h-6"
|
|
262
|
+
fill="none"
|
|
263
|
+
stroke="currentColor"
|
|
264
|
+
viewBox="0 0 24 24"
|
|
265
|
+
>
|
|
266
|
+
<path
|
|
267
|
+
stroke-linecap="round"
|
|
268
|
+
stroke-linejoin="round"
|
|
269
|
+
stroke-width="2"
|
|
270
|
+
d="M6 18L18 6M6 6l12 12"
|
|
271
|
+
/>
|
|
272
|
+
</svg>
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<Show when={error()}>
|
|
277
|
+
<div class="mb-4 p-2 border border-red-500 text-red-400 text-sm">
|
|
278
|
+
{error()}
|
|
279
|
+
</div>
|
|
280
|
+
</Show>
|
|
281
|
+
|
|
282
|
+
{/* p2p node status */}
|
|
283
|
+
<div class="mb-6">
|
|
284
|
+
<Show
|
|
285
|
+
when={p2pEnabled()}
|
|
286
|
+
fallback={
|
|
287
|
+
<button
|
|
288
|
+
onClick={handleEnableP2P}
|
|
289
|
+
disabled={starting()}
|
|
290
|
+
class="w-full px-4 py-3 bg-magenta-500 hover:bg-magenta-600 disabled:bg-magenta-400 text-white font-medium"
|
|
291
|
+
>
|
|
292
|
+
{starting() ? "starting p2p node..." : "enable p2p sharing"}
|
|
293
|
+
</button>
|
|
294
|
+
}
|
|
295
|
+
>
|
|
296
|
+
<div class="text-sm space-y-2">
|
|
297
|
+
<div
|
|
298
|
+
class="flex items-center gap-2"
|
|
299
|
+
title={
|
|
300
|
+
leader()
|
|
301
|
+
? "this tab runs the p2p node"
|
|
302
|
+
: "another tab holds the p2p node"
|
|
303
|
+
}
|
|
304
|
+
>
|
|
305
|
+
<span
|
|
306
|
+
class={`inline-block w-2 h-2 rounded-full ${leader() ? "bg-green-500" : "bg-yellow-500"}`}
|
|
307
|
+
/>
|
|
308
|
+
<span class="text-gray-300">online</span>
|
|
309
|
+
</div>
|
|
310
|
+
<Show when={nodeId()}>
|
|
311
|
+
<div class="flex items-center gap-2">
|
|
312
|
+
<span class="text-gray-500">node id:</span>
|
|
313
|
+
<code class="text-xs text-magenta-400 truncate flex-1">
|
|
314
|
+
{nodeId()}
|
|
315
|
+
</code>
|
|
316
|
+
<button
|
|
317
|
+
onClick={handleCopyNodeId}
|
|
318
|
+
title="copy node id"
|
|
319
|
+
class="text-gray-400 hover:text-white text-xs border border-gray-600 px-2 py-1"
|
|
320
|
+
>
|
|
321
|
+
{copied() ? "copied!" : "copy"}
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</Show>
|
|
325
|
+
<div class="text-gray-500 text-xs">
|
|
326
|
+
peers: {connSummary().connected} connected
|
|
327
|
+
<Show when={connSummary().reconnecting > 0}>
|
|
328
|
+
, {connSummary().reconnecting} reconnecting
|
|
329
|
+
</Show>
|
|
330
|
+
<Show when={connSummary().failed > 0}>
|
|
331
|
+
, {connSummary().failed} failed
|
|
332
|
+
</Show>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</Show>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{/* endpoint settings */}
|
|
339
|
+
<div class="mb-6 space-y-3">
|
|
340
|
+
<div>
|
|
341
|
+
<label class="block text-sm text-gray-400 mb-1">
|
|
342
|
+
display name
|
|
343
|
+
</label>
|
|
344
|
+
<input
|
|
345
|
+
type="text"
|
|
346
|
+
value={settings().name}
|
|
347
|
+
placeholder="anonymous"
|
|
348
|
+
onChange={(e) =>
|
|
349
|
+
void handleSaveSettings({ name: e.currentTarget.value })
|
|
350
|
+
}
|
|
351
|
+
class="w-full bg-black text-white px-3 py-2 text-sm border border-magenta-200 focus:border-magenta-500 focus:outline-none"
|
|
352
|
+
/>
|
|
353
|
+
</div>
|
|
354
|
+
<div>
|
|
355
|
+
<label class="block text-sm text-gray-400 mb-1">
|
|
356
|
+
who can browse my playlistz?
|
|
357
|
+
</label>
|
|
358
|
+
<div class="flex gap-2">
|
|
359
|
+
<button
|
|
360
|
+
onClick={() => void handleSaveSettings({ mode: "public" })}
|
|
361
|
+
class={`flex-1 px-3 py-2 text-sm border ${settings().mode === "public" ? "border-magenta-500 bg-magenta-500/20 text-white" : "border-gray-600 text-gray-400"}`}
|
|
362
|
+
>
|
|
363
|
+
anyone (public)
|
|
364
|
+
</button>
|
|
365
|
+
<button
|
|
366
|
+
onClick={() => void handleSaveSettings({ mode: "knock" })}
|
|
367
|
+
class={`flex-1 px-3 py-2 text-sm border ${settings().mode === "knock" ? "border-magenta-500 bg-magenta-500/20 text-white" : "border-gray-600 text-gray-400"}`}
|
|
368
|
+
>
|
|
369
|
+
knock first
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
{/* open a share link */}
|
|
376
|
+
<div class="mb-6">
|
|
377
|
+
<label class="block text-sm text-gray-400 mb-1">
|
|
378
|
+
open a share link
|
|
379
|
+
</label>
|
|
380
|
+
<div class="flex flex-col gap-2">
|
|
381
|
+
<input
|
|
382
|
+
type="text"
|
|
383
|
+
value={pasteValue()}
|
|
384
|
+
placeholder="paste share link or token..."
|
|
385
|
+
onInput={(e) => setPasteValue(e.currentTarget.value)}
|
|
386
|
+
onKeyDown={(e) => {
|
|
387
|
+
if (e.key === "Enter") void handleOpenLink();
|
|
388
|
+
}}
|
|
389
|
+
class="w-full bg-black text-white px-3 py-2 text-sm border border-magenta-200 focus:border-magenta-500 focus:outline-none"
|
|
390
|
+
/>
|
|
391
|
+
<div class="flex gap-2">
|
|
392
|
+
<button
|
|
393
|
+
onClick={() => void handleOpenLink()}
|
|
394
|
+
class="flex-1 px-4 py-2 bg-magenta-500 hover:bg-magenta-600 text-white text-sm"
|
|
395
|
+
>
|
|
396
|
+
open
|
|
397
|
+
</button>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
<Show when={pasteStatus()}>
|
|
401
|
+
<div class="mt-1 text-xs text-magenta-400">{pasteStatus()}</div>
|
|
402
|
+
</Show>
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
{/* browse a peer */}
|
|
406
|
+
<div class="mb-6">
|
|
407
|
+
<label class="block text-sm text-gray-400 mb-1">
|
|
408
|
+
browse a peer's playlistz
|
|
409
|
+
</label>
|
|
410
|
+
<div class="flex flex-col gap-2">
|
|
411
|
+
<input
|
|
412
|
+
type="text"
|
|
413
|
+
value={browseNodeId()}
|
|
414
|
+
placeholder="peer node id..."
|
|
415
|
+
onInput={(e) => setBrowseNodeId(e.currentTarget.value)}
|
|
416
|
+
class="w-full bg-black text-white px-3 py-2 text-sm border border-magenta-200 focus:border-magenta-500 focus:outline-none"
|
|
417
|
+
/>
|
|
418
|
+
<div class="flex gap-2">
|
|
419
|
+
<button
|
|
420
|
+
onClick={() => void handleBrowsePeer()}
|
|
421
|
+
class="flex-1 px-3 py-2 border border-magenta-500 text-magenta-400 hover:bg-magenta-500/20 text-sm"
|
|
422
|
+
>
|
|
423
|
+
browse
|
|
424
|
+
</button>
|
|
425
|
+
<button
|
|
426
|
+
onClick={() => void handleKnock()}
|
|
427
|
+
class="flex-1 px-3 py-2 border border-gray-600 text-gray-300 hover:bg-gray-800 text-sm"
|
|
428
|
+
title="ask this peer for access"
|
|
429
|
+
>
|
|
430
|
+
knock
|
|
431
|
+
</button>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
<Show when={browseStatus()}>
|
|
435
|
+
<div class="mt-1 text-xs text-magenta-400">{browseStatus()}</div>
|
|
436
|
+
</Show>
|
|
437
|
+
<Show when={browseResult()}>
|
|
438
|
+
{(listing) => (
|
|
439
|
+
<div class="mt-2 text-sm">
|
|
440
|
+
<Show
|
|
441
|
+
when={listing().items.length > 0}
|
|
442
|
+
fallback={
|
|
443
|
+
<div class="text-gray-500 text-xs">
|
|
444
|
+
{listing().knockRequired
|
|
445
|
+
? "this peer requires a knock"
|
|
446
|
+
: "no playlistz shared"}
|
|
447
|
+
</div>
|
|
448
|
+
}
|
|
449
|
+
>
|
|
450
|
+
<For each={listing().items}>
|
|
451
|
+
{(item) => (
|
|
452
|
+
<div class="flex items-center justify-between py-1 border-b border-gray-800">
|
|
453
|
+
<span>
|
|
454
|
+
{item.title}{" "}
|
|
455
|
+
<span class="text-gray-500 text-xs">
|
|
456
|
+
({item.songCount} songz)
|
|
457
|
+
</span>
|
|
458
|
+
</span>
|
|
459
|
+
<button
|
|
460
|
+
onClick={() =>
|
|
461
|
+
void (async () => {
|
|
462
|
+
try {
|
|
463
|
+
const result = await openShareLink(
|
|
464
|
+
// build a minimal token from the listing
|
|
465
|
+
`#share/${btoa(
|
|
466
|
+
JSON.stringify({
|
|
467
|
+
v: 1,
|
|
468
|
+
n: listing().nodeId,
|
|
469
|
+
d: item.docId,
|
|
470
|
+
t: item.title,
|
|
471
|
+
})
|
|
472
|
+
)
|
|
473
|
+
.replace(/\+/g, "-")
|
|
474
|
+
.replace(/\//g, "_")
|
|
475
|
+
.replace(/=/g, "")}`
|
|
476
|
+
);
|
|
477
|
+
if (result.status === "synced") {
|
|
478
|
+
props.onPlaylistAdded?.(result.docId);
|
|
479
|
+
}
|
|
480
|
+
} catch (err) {
|
|
481
|
+
setError(
|
|
482
|
+
err instanceof Error
|
|
483
|
+
? err.message
|
|
484
|
+
: "failed to add playlist"
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
})()
|
|
488
|
+
}
|
|
489
|
+
class="text-xs text-magenta-400 hover:text-magenta-300 border border-magenta-500 px-2 py-1"
|
|
490
|
+
>
|
|
491
|
+
add
|
|
492
|
+
</button>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
</For>
|
|
496
|
+
</Show>
|
|
497
|
+
</div>
|
|
498
|
+
)}
|
|
499
|
+
</Show>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
{/* knock inbox */}
|
|
503
|
+
<div>
|
|
504
|
+
<label class="block text-sm text-gray-400 mb-1">
|
|
505
|
+
knock inbox
|
|
506
|
+
<Show when={pendingKnocks().length > 0}>
|
|
507
|
+
<span class="ml-2 text-magenta-400">
|
|
508
|
+
({pendingKnocks().length} pending)
|
|
509
|
+
</span>
|
|
510
|
+
</Show>
|
|
511
|
+
</label>
|
|
512
|
+
<Show
|
|
513
|
+
when={pendingKnocks().length > 0}
|
|
514
|
+
fallback={
|
|
515
|
+
<div class="text-gray-600 text-xs">no pending knockz</div>
|
|
516
|
+
}
|
|
517
|
+
>
|
|
518
|
+
<For each={pendingKnocks()}>
|
|
519
|
+
{(knock) => (
|
|
520
|
+
<div class="border border-gray-700 p-3 mb-2 text-sm">
|
|
521
|
+
<div class="mb-1">
|
|
522
|
+
<span class="text-white">
|
|
523
|
+
{knock.name || "anonymous"}
|
|
524
|
+
</span>
|
|
525
|
+
<span class="text-gray-500 text-xs ml-2">
|
|
526
|
+
{knock.nodeId.slice(0, 16)}...
|
|
527
|
+
</span>
|
|
528
|
+
</div>
|
|
529
|
+
<Show when={knock.message}>
|
|
530
|
+
<div class="text-gray-400 text-xs mb-2">
|
|
531
|
+
"{knock.message}"
|
|
532
|
+
</div>
|
|
533
|
+
</Show>
|
|
534
|
+
<div class="text-xs text-gray-500 mb-2">
|
|
535
|
+
grant access to:
|
|
536
|
+
</div>
|
|
537
|
+
<div class="max-h-24 overflow-y-auto mb-2">
|
|
538
|
+
<For each={props.playlists}>
|
|
539
|
+
{(pl) => (
|
|
540
|
+
<label class="flex items-center gap-2 text-xs text-gray-300 py-0.5 cursor-pointer">
|
|
541
|
+
<input
|
|
542
|
+
type="checkbox"
|
|
543
|
+
checked={
|
|
544
|
+
grantSelection()[knock.id]?.has(pl.id) ?? false
|
|
545
|
+
}
|
|
546
|
+
onChange={() => toggleGrantDoc(knock.id, pl.id)}
|
|
547
|
+
/>
|
|
548
|
+
{pl.title}
|
|
549
|
+
</label>
|
|
550
|
+
)}
|
|
551
|
+
</For>
|
|
552
|
+
</div>
|
|
553
|
+
<div class="flex gap-2">
|
|
554
|
+
<button
|
|
555
|
+
onClick={() => void handleAccept(knock)}
|
|
556
|
+
class="flex-1 px-3 py-1 bg-magenta-500 hover:bg-magenta-600 text-white text-xs"
|
|
557
|
+
title={
|
|
558
|
+
(grantSelection()[knock.id]?.size ?? 0) > 0
|
|
559
|
+
? "grant selected playlistz"
|
|
560
|
+
: "grant all playlistz"
|
|
561
|
+
}
|
|
562
|
+
>
|
|
563
|
+
accept
|
|
564
|
+
{(grantSelection()[knock.id]?.size ?? 0) > 0
|
|
565
|
+
? ` (${grantSelection()[knock.id]!.size})`
|
|
566
|
+
: " (all)"}
|
|
567
|
+
</button>
|
|
568
|
+
<button
|
|
569
|
+
onClick={() => void handleDeny(knock)}
|
|
570
|
+
class="flex-1 px-3 py-1 border border-gray-600 text-gray-300 hover:bg-gray-800 text-xs"
|
|
571
|
+
>
|
|
572
|
+
deny
|
|
573
|
+
</button>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
</For>
|
|
578
|
+
</Show>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
</Show>
|
|
583
|
+
);
|
|
584
|
+
}
|