@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,1203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Accessor,
|
|
3
|
+
Show,
|
|
4
|
+
For,
|
|
5
|
+
createSignal,
|
|
6
|
+
createEffect,
|
|
7
|
+
createMemo,
|
|
8
|
+
on,
|
|
9
|
+
onCleanup,
|
|
10
|
+
onMount,
|
|
11
|
+
} from "solid-js";
|
|
12
|
+
import type { Playlist, Song } from "../../types/playlist.js";
|
|
13
|
+
import {
|
|
14
|
+
usePlaylistzManager,
|
|
15
|
+
usePlaylistzSongs,
|
|
16
|
+
usePlaylistzUI,
|
|
17
|
+
usePlaylistzImageModal,
|
|
18
|
+
usePlaylistzDragDrop,
|
|
19
|
+
} from "../../context/PlaylistzContext.js";
|
|
20
|
+
import { getImageUrlForContext } from "../../services/imageService.js";
|
|
21
|
+
import { audioState } from "../../services/audioService.js";
|
|
22
|
+
import {
|
|
23
|
+
initSharingState,
|
|
24
|
+
sharingReady,
|
|
25
|
+
pendingKnockCount,
|
|
26
|
+
outboundPendingCount,
|
|
27
|
+
connectedPeerCount,
|
|
28
|
+
isTransferring,
|
|
29
|
+
} from "../../services/sharingState.js";
|
|
30
|
+
import {
|
|
31
|
+
savePlaylistOffline,
|
|
32
|
+
playlistHasMissingBlobs,
|
|
33
|
+
type OfflineProgress,
|
|
34
|
+
} from "../../services/blobTransferService.js";
|
|
35
|
+
import { AudioPlayer } from "../AudioPlayer.js";
|
|
36
|
+
import { SongRow } from "../SongRow.js";
|
|
37
|
+
import { PlaylistEditPanel } from "../PlaylistEditPanel.js";
|
|
38
|
+
import { SongEditPanel } from "../SongEditPanel.js";
|
|
39
|
+
import { PlaylistSharePanel } from "../PlaylistSharePanel.js";
|
|
40
|
+
import { AllPlaylistsPanel } from "../AllPlaylistsPanel.js";
|
|
41
|
+
import { forkPlaylist } from "../../services/playlistDocService.js";
|
|
42
|
+
|
|
43
|
+
import { log } from "../../utils/log.js";
|
|
44
|
+
|
|
45
|
+
export function PlaylistContainer(props: { playlist: Accessor<Playlist> }) {
|
|
46
|
+
const playlistManager = usePlaylistzManager();
|
|
47
|
+
const songState = usePlaylistzSongs();
|
|
48
|
+
const uiState = usePlaylistzUI();
|
|
49
|
+
const imageModal = usePlaylistzImageModal();
|
|
50
|
+
const dragDrop = usePlaylistzDragDrop();
|
|
51
|
+
|
|
52
|
+
onMount(() => initSharingState());
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
playlists,
|
|
56
|
+
playlistSongs,
|
|
57
|
+
isDownloading,
|
|
58
|
+
isCaching,
|
|
59
|
+
allSongsCached,
|
|
60
|
+
handlePlaylistUpdate,
|
|
61
|
+
handleDownloadPlaylist,
|
|
62
|
+
handleCachePlaylist,
|
|
63
|
+
handleRemoveSong,
|
|
64
|
+
handleReorderSongs,
|
|
65
|
+
setBackgroundOverride,
|
|
66
|
+
} = playlistManager;
|
|
67
|
+
|
|
68
|
+
// read-only mode: playlist is subscribed from a remote peer and not yet forked
|
|
69
|
+
const isSubscribed = () =>
|
|
70
|
+
!!props.playlist().remoteNodeId && !props.playlist().isForked;
|
|
71
|
+
|
|
72
|
+
const {
|
|
73
|
+
handleEditSong,
|
|
74
|
+
handleEditPlaylist,
|
|
75
|
+
handlePlaySong,
|
|
76
|
+
handlePauseSong,
|
|
77
|
+
editingSong,
|
|
78
|
+
editingPlaylist,
|
|
79
|
+
setEditingSong,
|
|
80
|
+
handleCloseEdit,
|
|
81
|
+
handleSongSaved,
|
|
82
|
+
} = songState;
|
|
83
|
+
|
|
84
|
+
// create a wrapper that passes the playlist context
|
|
85
|
+
const handlePlaySongWithPlaylist = async (song: Song) => {
|
|
86
|
+
await handlePlaySong(song, props.playlist());
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// p2p save offline: fetch all missing blobs from the doc's peers
|
|
90
|
+
const [p2pSaveProgress, setP2pSaveProgress] =
|
|
91
|
+
createSignal<OfflineProgress | null>(null);
|
|
92
|
+
// hide the save-offline button once every referenced blob is local
|
|
93
|
+
const [p2pHasMissing, setP2pHasMissing] = createSignal(false);
|
|
94
|
+
createEffect(
|
|
95
|
+
on(
|
|
96
|
+
// re-check whenever the song list changes OR after a save-offline run
|
|
97
|
+
// completes (p2pSaveProgress transitions back to null)
|
|
98
|
+
() =>
|
|
99
|
+
[
|
|
100
|
+
props.playlist().id,
|
|
101
|
+
playlistSongs().length,
|
|
102
|
+
p2pSaveProgress() === null,
|
|
103
|
+
] as const,
|
|
104
|
+
() => {
|
|
105
|
+
void playlistHasMissingBlobs(props.playlist())
|
|
106
|
+
.then(setP2pHasMissing)
|
|
107
|
+
.catch(() => setP2pHasMissing(false));
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
const handleP2pSaveOffline = async () => {
|
|
112
|
+
if (p2pSaveProgress()) return;
|
|
113
|
+
setP2pSaveProgress({ done: 0, total: 0, currentTitle: "", fraction: 0 });
|
|
114
|
+
try {
|
|
115
|
+
await savePlaylistOffline(props.playlist(), (p) => setP2pSaveProgress(p));
|
|
116
|
+
setP2pHasMissing(
|
|
117
|
+
await playlistHasMissingBlobs(props.playlist()).catch(() => false)
|
|
118
|
+
);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log.warn("p2p.save", "p2p save offline failed:", err);
|
|
121
|
+
} finally {
|
|
122
|
+
setP2pSaveProgress(null);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const { isMobile } = uiState;
|
|
127
|
+
|
|
128
|
+
const { openImageModal } = imageModal;
|
|
129
|
+
|
|
130
|
+
// share panel state - declared before isEditing so the memo can reference it
|
|
131
|
+
const [showingShare, setShowingShare] = createSignal(false);
|
|
132
|
+
const [showAllPlaylists, setShowAllPlaylists] = createSignal(false);
|
|
133
|
+
// when set, AllPlaylistsPanel opens with this peer nodeId pre-searched
|
|
134
|
+
const [allPlaylistsPeerQuery, setAllPlaylistsPeerQuery] = createSignal<
|
|
135
|
+
string | undefined
|
|
136
|
+
>(undefined);
|
|
137
|
+
|
|
138
|
+
const closeShare = () => {
|
|
139
|
+
setShowingShare(false);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// true when any edit panel, share panel, or all-playlists view is open.
|
|
143
|
+
const isEditing = createMemo(
|
|
144
|
+
() =>
|
|
145
|
+
editingSong() !== null ||
|
|
146
|
+
editingPlaylist() ||
|
|
147
|
+
showingShare() ||
|
|
148
|
+
showAllPlaylists()
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// index of the song being edited (for directional row animation)
|
|
152
|
+
const editingSongIndex = () => {
|
|
153
|
+
const song = editingSong();
|
|
154
|
+
if (!song) return -1;
|
|
155
|
+
return props.playlist().songIds.indexOf(song.id);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// neighbouring song relative to the one being edited (for panel navigation)
|
|
159
|
+
const songAtOffset = (offset: number): Song | undefined => {
|
|
160
|
+
const idx = editingSongIndex();
|
|
161
|
+
if (idx < 0) return undefined;
|
|
162
|
+
const targetId = props.playlist().songIds[idx + offset];
|
|
163
|
+
if (!targetId) return undefined;
|
|
164
|
+
return playlistSongs().find((s) => s.id === targetId);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const FLYOUT_MS = 100;
|
|
168
|
+
|
|
169
|
+
// stagger delay per row during exit (in ms)
|
|
170
|
+
const rowExitDelayMs = (index: number): number =>
|
|
171
|
+
index < 5 ? index * 20 : 50 + (index - 5) * 5;
|
|
172
|
+
|
|
173
|
+
// which CSS keyframe to use for a row's exit
|
|
174
|
+
const rowExitKeyframe = (rowIndex: number): string => {
|
|
175
|
+
if (editingPlaylist() || showAllPlaylists()) return "rowFlyDown";
|
|
176
|
+
const editIdx = editingSongIndex();
|
|
177
|
+
if (editIdx >= 0) {
|
|
178
|
+
return rowIndex < editIdx ? "rowFlyUp" : "rowFlyDown";
|
|
179
|
+
}
|
|
180
|
+
return "rowFlyDown";
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// phase signal: tracks whether rows have completed their exit animation.
|
|
184
|
+
// "gone" = rows are done animating and should be collapsed out of layout.
|
|
185
|
+
const [rowsGone, setRowsGone] = createSignal(false);
|
|
186
|
+
|
|
187
|
+
// scroll container ref - reset scroll when an edit panel opens so the
|
|
188
|
+
// panel top is never cut off (e.g. when editing songs at the end of a long playlist)
|
|
189
|
+
let scrollContainerRef: HTMLDivElement | undefined;
|
|
190
|
+
|
|
191
|
+
createEffect(() => {
|
|
192
|
+
if (isEditing()) {
|
|
193
|
+
scrollContainerRef?.scrollTo({ top: 0 });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// in playlist edit mode, a song edit panel is shown below the playlist
|
|
198
|
+
// panel. default it to the first song, and re-sync when switching
|
|
199
|
+
// playlists (the previous playlist's song may not exist here)
|
|
200
|
+
createEffect(() => {
|
|
201
|
+
if (!editingPlaylist()) return;
|
|
202
|
+
const ids = props.playlist().songIds || [];
|
|
203
|
+
const current = editingSong();
|
|
204
|
+
if (current && ids.includes(current.id)) return;
|
|
205
|
+
const first = ids.length
|
|
206
|
+
? playlistSongs().find((s) => s.id === ids[0])
|
|
207
|
+
: undefined;
|
|
208
|
+
setEditingSong(first ?? null);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// while in playlist edit mode, the page background follows the song being
|
|
212
|
+
// edited (if it has an image) so the filter sliders are easier to tune.
|
|
213
|
+
// the "use cover" button in the edit panel can override this until the
|
|
214
|
+
// editing song changes again. cleared when leaving edit mode
|
|
215
|
+
createEffect(
|
|
216
|
+
on([editingPlaylist, editingSong], ([inPlaylistEdit, song]) => {
|
|
217
|
+
if (inPlaylistEdit && song?.imageType) {
|
|
218
|
+
setBackgroundOverride(song);
|
|
219
|
+
} else {
|
|
220
|
+
setBackgroundOverride(null);
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
onCleanup(() => setBackgroundOverride(null));
|
|
226
|
+
|
|
227
|
+
// escape key closes the edit panels or share panel
|
|
228
|
+
onMount(() => {
|
|
229
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
230
|
+
if (e.key === "Escape") {
|
|
231
|
+
if (showAllPlaylists()) {
|
|
232
|
+
setShowAllPlaylists(false);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (showingShare()) {
|
|
236
|
+
closeShare();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (isEditing()) handleCloseEdit();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
document.addEventListener("keydown", onKeyDown);
|
|
243
|
+
onCleanup(() => document.removeEventListener("keydown", onKeyDown));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// only animate rows on the closed -> open transition. navigating between
|
|
247
|
+
// song edit panels keeps isEditing() true, so rowsGone stays true and the
|
|
248
|
+
// hidden rows don't flash back in between panels
|
|
249
|
+
createEffect(
|
|
250
|
+
on(isEditing, (editing, prevEditing) => {
|
|
251
|
+
log.debug(
|
|
252
|
+
"playlist.rows",
|
|
253
|
+
"rowsGone effect",
|
|
254
|
+
JSON.stringify({ editing, prevEditing })
|
|
255
|
+
);
|
|
256
|
+
if (editing && !prevEditing) {
|
|
257
|
+
setRowsGone(false);
|
|
258
|
+
// collapse layout and show panel after the first few rows have started
|
|
259
|
+
// exiting - remaining row animations complete behind the panel
|
|
260
|
+
const totalMs = rowExitDelayMs(2) + FLYOUT_MS;
|
|
261
|
+
const t = setTimeout(() => {
|
|
262
|
+
log.debug("playlist.rows", "rowsGone -> true");
|
|
263
|
+
setRowsGone(true);
|
|
264
|
+
}, totalMs);
|
|
265
|
+
onCleanup(() => clearTimeout(t));
|
|
266
|
+
} else if (!editing) {
|
|
267
|
+
setRowsGone(false);
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// outer wrapper: collapses to 0 height ONLY after animation completes.
|
|
273
|
+
// no overflow:hidden here so inner transforms can fly freely.
|
|
274
|
+
const rowOuterStyle = () =>
|
|
275
|
+
rowsGone()
|
|
276
|
+
? { "max-height": "0px", overflow: "hidden" as const }
|
|
277
|
+
: { "max-height": "400px" };
|
|
278
|
+
|
|
279
|
+
// inner wrapper: CSS keyframe animation.
|
|
280
|
+
// animation-name changes trigger a fresh animation on every edit mode transition.
|
|
281
|
+
const rowInnerStyle = (rowIndex: number) => {
|
|
282
|
+
if (isEditing() && !rowsGone()) {
|
|
283
|
+
const delay = rowExitDelayMs(rowIndex);
|
|
284
|
+
return {
|
|
285
|
+
animation: `${rowExitKeyframe(rowIndex)} ${FLYOUT_MS}ms ease ${delay}ms both`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (!isEditing()) {
|
|
289
|
+
// fly back in when returning from edit mode (all rows together, subtle)
|
|
290
|
+
return { animation: `rowFlyIn ${FLYOUT_MS}ms ease both` };
|
|
291
|
+
}
|
|
292
|
+
return {};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// header collapses out of layout only when editing a specific song (and not
|
|
296
|
+
// in playlist edit mode). stays visible for share, all-playlists, and
|
|
297
|
+
// playlist edit mode.
|
|
298
|
+
// overflow:hidden only applied while collapsing so it doesn't clip mobile content.
|
|
299
|
+
const headerStyle = () =>
|
|
300
|
+
editingSong() && !editingPlaylist()
|
|
301
|
+
? {
|
|
302
|
+
transition: "max-height 350ms ease, opacity 300ms ease",
|
|
303
|
+
"max-height": "0px",
|
|
304
|
+
overflow: "hidden" as const,
|
|
305
|
+
opacity: "0",
|
|
306
|
+
"pointer-events": "none" as const,
|
|
307
|
+
}
|
|
308
|
+
: {
|
|
309
|
+
transition: "max-height 350ms ease, opacity 300ms ease",
|
|
310
|
+
"max-height": "1200px",
|
|
311
|
+
opacity: "1",
|
|
312
|
+
"pointer-events": "auto" as const,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// panel slides in immediately after rows have collapsed (panel only mounts when rowsGone())
|
|
316
|
+
const panelEntryStyle = () =>
|
|
317
|
+
({ animation: "slideDown 150ms ease both" }) as const;
|
|
318
|
+
|
|
319
|
+
// height of the mobile sticky controls bar - active song rows stick just
|
|
320
|
+
// below it instead of hiding underneath
|
|
321
|
+
let stickyBarRef: HTMLDivElement | undefined;
|
|
322
|
+
const [stickyBarHeight, setStickyBarHeight] = createSignal(0);
|
|
323
|
+
createEffect(() => {
|
|
324
|
+
if (!isMobile()) {
|
|
325
|
+
setStickyBarHeight(0);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const el = stickyBarRef;
|
|
329
|
+
if (!el) return;
|
|
330
|
+
setStickyBarHeight(el.offsetHeight);
|
|
331
|
+
const ro = new ResizeObserver(() => setStickyBarHeight(el.offsetHeight));
|
|
332
|
+
ro.observe(el);
|
|
333
|
+
onCleanup(() => ro.disconnect());
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<div
|
|
338
|
+
class={`flex-1 flex flex-col min-h-0 [overflow-x:clip] ${isMobile() ? "p-2" : "p-6"}`}
|
|
339
|
+
>
|
|
340
|
+
{(() => {
|
|
341
|
+
// playlist header - animates up/out only when editing a specific song
|
|
342
|
+
// (not in playlist edit mode). stays visible for share, all-playlists,
|
|
343
|
+
// and playlist edit mode. on mobile it renders inside the scroll
|
|
344
|
+
// container so the cover image + title scroll with content, while the
|
|
345
|
+
// player controls bar stays sticky at the top
|
|
346
|
+
const headerSection = () => (
|
|
347
|
+
<div
|
|
348
|
+
style={headerStyle()}
|
|
349
|
+
class={`flex items-center justify-between ${isMobile() ? "flex-col" : "p-6"}`}
|
|
350
|
+
>
|
|
351
|
+
{/* playlist cover image for mobile - hidden in edit mode (edit panel has its own) */}
|
|
352
|
+
<div class={`${isMobile() && !isEditing() ? "" : "hidden"}`}>
|
|
353
|
+
<button
|
|
354
|
+
onClick={() => {
|
|
355
|
+
openImageModal(props.playlist(), playlistSongs(), 0);
|
|
356
|
+
}}
|
|
357
|
+
class="w-full h-full overflow-hidden hover:bg-gray-900 flex items-center justify-center transition-colors group"
|
|
358
|
+
title="view playlist images"
|
|
359
|
+
>
|
|
360
|
+
<Show
|
|
361
|
+
when={props.playlist().imageType}
|
|
362
|
+
fallback={
|
|
363
|
+
<div class="text-center">
|
|
364
|
+
<svg
|
|
365
|
+
width="100"
|
|
366
|
+
height="100"
|
|
367
|
+
viewBox="0 0 100 100"
|
|
368
|
+
fill="none"
|
|
369
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
370
|
+
>
|
|
371
|
+
<path
|
|
372
|
+
d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
|
|
373
|
+
fill="#FF00FF"
|
|
374
|
+
/>
|
|
375
|
+
</svg>
|
|
376
|
+
</div>
|
|
377
|
+
}
|
|
378
|
+
>
|
|
379
|
+
{(() => {
|
|
380
|
+
const imageUrl = getImageUrlForContext(
|
|
381
|
+
props.playlist(),
|
|
382
|
+
"modal"
|
|
383
|
+
);
|
|
384
|
+
return (
|
|
385
|
+
<>
|
|
386
|
+
{imageUrl ? (
|
|
387
|
+
<img
|
|
388
|
+
src={imageUrl}
|
|
389
|
+
alt="playlist cover"
|
|
390
|
+
class="w-full h-full object-cover"
|
|
391
|
+
/>
|
|
392
|
+
) : (
|
|
393
|
+
<div class="text-center">
|
|
394
|
+
<svg
|
|
395
|
+
width="100"
|
|
396
|
+
height="100"
|
|
397
|
+
viewBox="0 0 100 100"
|
|
398
|
+
fill="none"
|
|
399
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
400
|
+
>
|
|
401
|
+
<path
|
|
402
|
+
d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
|
|
403
|
+
fill="#FF00FF"
|
|
404
|
+
/>
|
|
405
|
+
</svg>
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
</>
|
|
409
|
+
);
|
|
410
|
+
})()}
|
|
411
|
+
</Show>
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
<div class="flex items-center gap-4 w-full">
|
|
416
|
+
<div class="flex-1">
|
|
417
|
+
<div class={`bg-black bg-opacity-80`}>
|
|
418
|
+
<input
|
|
419
|
+
data-testid="input-playlist-title"
|
|
420
|
+
type="text"
|
|
421
|
+
value={props.playlist().title}
|
|
422
|
+
onInput={(e) => {
|
|
423
|
+
handlePlaylistUpdate({
|
|
424
|
+
title: e.currentTarget.value,
|
|
425
|
+
});
|
|
426
|
+
}}
|
|
427
|
+
disabled={isSubscribed()}
|
|
428
|
+
class="text-3xl font-bold text-white bg-transparent border-none outline-none focus:bg-gray-800 px-2 py-1 rounded w-full disabled:opacity-60 disabled:cursor-not-allowed"
|
|
429
|
+
placeholder="playlist title"
|
|
430
|
+
/>
|
|
431
|
+
</div>
|
|
432
|
+
<div class={`bg-black bg-opacity-80`}>
|
|
433
|
+
<input
|
|
434
|
+
data-testid="input-playlist-description"
|
|
435
|
+
type="text"
|
|
436
|
+
value={props.playlist().description || ""}
|
|
437
|
+
placeholder="add description..."
|
|
438
|
+
onInput={(e) => {
|
|
439
|
+
handlePlaylistUpdate({
|
|
440
|
+
description: e.currentTarget.value,
|
|
441
|
+
});
|
|
442
|
+
}}
|
|
443
|
+
disabled={isSubscribed()}
|
|
444
|
+
class="text-white bg-transparent border-none focus:bg-gray-800 px-2 py-1 rounded w-full disabled:opacity-60 disabled:cursor-not-allowed"
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
{/* read-only banner for subscribed playlists */}
|
|
449
|
+
<Show when={isSubscribed()}>
|
|
450
|
+
<SubscribedBanner
|
|
451
|
+
playlist={props.playlist()}
|
|
452
|
+
onFork={(newDocId) => {
|
|
453
|
+
playlistManager.selectById(newDocId);
|
|
454
|
+
}}
|
|
455
|
+
/>
|
|
456
|
+
</Show>
|
|
457
|
+
|
|
458
|
+
{/* player + action buttons grid - inline here on desktop, a
|
|
459
|
+
sticky bar inside the scroll container on mobile */}
|
|
460
|
+
<Show when={!isMobile()}>{playerControls()}</Show>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
{/* playlist cover image (desktop) */}
|
|
465
|
+
{coverImage()}
|
|
466
|
+
</div>
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// hoisted function declarations so headerSection (above) and the
|
|
470
|
+
// mobile sticky bar (below) can both render these
|
|
471
|
+
function playerControls() {
|
|
472
|
+
// 2x2 grid layout with AudioPlayer spanning left side
|
|
473
|
+
return (
|
|
474
|
+
<div
|
|
475
|
+
class="grid gap-3"
|
|
476
|
+
style={{
|
|
477
|
+
"grid-template-columns": "auto 1fr",
|
|
478
|
+
"grid-template-areas": "'player info' 'player buttons'",
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
{/* AudioPlayer spans 2 rows on the left */}
|
|
482
|
+
<div
|
|
483
|
+
class="flex items-center justify-center"
|
|
484
|
+
style={{ "grid-area": "player" }}
|
|
485
|
+
>
|
|
486
|
+
<AudioPlayer playlist={props.playlist()} size="w-12 h-12" />
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
{/* top right song info stuff */}
|
|
490
|
+
<div
|
|
491
|
+
id="song-info"
|
|
492
|
+
class="flex items-center justify-end text-sm gap-0"
|
|
493
|
+
style={{ "grid-area": "info" }}
|
|
494
|
+
>
|
|
495
|
+
{/* sharer identity pill - shown when playlist is subscribed from a remote peer */}
|
|
496
|
+
<Show when={props.playlist().remoteNodeId}>
|
|
497
|
+
<button
|
|
498
|
+
data-testid="btn-browse-sharer"
|
|
499
|
+
class="flex items-center gap-1 bg-black/80 px-1.5 py-2 text-xs text-gray-400 hover:text-magenta-300 hover:bg-black transition-colors"
|
|
500
|
+
title={`browse ${props.playlist().remoteName || props.playlist().remoteNodeId?.slice(0, 16)}'s playlistz`}
|
|
501
|
+
onClick={() => {
|
|
502
|
+
if (showingShare()) closeShare();
|
|
503
|
+
if (editingPlaylist() || editingSong()) handleCloseEdit();
|
|
504
|
+
setAllPlaylistsPeerQuery(props.playlist().remoteNodeId);
|
|
505
|
+
setShowAllPlaylists(true);
|
|
506
|
+
}}
|
|
507
|
+
>
|
|
508
|
+
<Show
|
|
509
|
+
when={props.playlist().remoteAvatarDataUrl}
|
|
510
|
+
fallback={
|
|
511
|
+
<span class="inline-flex items-center justify-center w-4 h-4 bg-magenta-700/60 text-white text-[9px] font-bold shrink-0 overflow-hidden rounded-full">
|
|
512
|
+
{(
|
|
513
|
+
props.playlist().remoteName ||
|
|
514
|
+
props.playlist().remoteNodeId ||
|
|
515
|
+
""
|
|
516
|
+
)
|
|
517
|
+
.slice(0, 1)
|
|
518
|
+
.toUpperCase()}
|
|
519
|
+
</span>
|
|
520
|
+
}
|
|
521
|
+
>
|
|
522
|
+
<img
|
|
523
|
+
src={props.playlist().remoteAvatarDataUrl}
|
|
524
|
+
alt={props.playlist().remoteName || "peer"}
|
|
525
|
+
class="w-4 h-4 rounded-full object-cover shrink-0"
|
|
526
|
+
/>
|
|
527
|
+
</Show>
|
|
528
|
+
<span class="truncate max-w-[6rem]">
|
|
529
|
+
{props.playlist().remoteName ||
|
|
530
|
+
props.playlist().remoteNodeId?.slice(0, 8)}
|
|
531
|
+
</span>
|
|
532
|
+
</button>
|
|
533
|
+
</Show>
|
|
534
|
+
<span
|
|
535
|
+
data-testid="playlist-song-count"
|
|
536
|
+
class="bg-black bg-opacity-80 p-2"
|
|
537
|
+
>
|
|
538
|
+
{props.playlist().songIds?.length || 0} song
|
|
539
|
+
{(props.playlist().songIds?.length || 0) !== 1 ? "z" : ""}
|
|
540
|
+
</span>
|
|
541
|
+
<span
|
|
542
|
+
data-testid="playlist-total-time"
|
|
543
|
+
class="bg-black bg-opacity-80 p-2"
|
|
544
|
+
>
|
|
545
|
+
{(() => {
|
|
546
|
+
const totalSeconds = playlistSongs().reduce(
|
|
547
|
+
(total, song) => total + (song.duration || 0),
|
|
548
|
+
0
|
|
549
|
+
);
|
|
550
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
551
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
552
|
+
const seconds = Math.floor(totalSeconds % 60);
|
|
553
|
+
return hours > 0
|
|
554
|
+
? `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`
|
|
555
|
+
: `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
556
|
+
})()}
|
|
557
|
+
</span>
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
{/* bottom right: action buttonz */}
|
|
561
|
+
<div
|
|
562
|
+
class="flex items-center justify-end gap-2"
|
|
563
|
+
style={{ "grid-area": "buttons" }}
|
|
564
|
+
>
|
|
565
|
+
{/* hamburger: open all-playlists overlay */}
|
|
566
|
+
<button
|
|
567
|
+
data-testid="btn-all-playlists"
|
|
568
|
+
aria-expanded={showAllPlaylists()}
|
|
569
|
+
onClick={() => {
|
|
570
|
+
if (showingShare()) closeShare();
|
|
571
|
+
if (editingPlaylist() || editingSong()) handleCloseEdit();
|
|
572
|
+
setShowAllPlaylists((v) => !v);
|
|
573
|
+
}}
|
|
574
|
+
class={`p-2 hover:text-white hover:bg-gray-700 transition-colors bg-black/90 border ${
|
|
575
|
+
showAllPlaylists()
|
|
576
|
+
? "text-magenta-400 border-magenta-500"
|
|
577
|
+
: "text-gray-400 border-transparent"
|
|
578
|
+
}`}
|
|
579
|
+
title="all playlistz"
|
|
580
|
+
>
|
|
581
|
+
<svg
|
|
582
|
+
class="w-4 h-4"
|
|
583
|
+
fill="none"
|
|
584
|
+
stroke="currentColor"
|
|
585
|
+
viewBox="0 0 24 24"
|
|
586
|
+
>
|
|
587
|
+
<path
|
|
588
|
+
stroke-linecap="round"
|
|
589
|
+
stroke-linejoin="round"
|
|
590
|
+
stroke-width="2"
|
|
591
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
592
|
+
/>
|
|
593
|
+
</svg>
|
|
594
|
+
</button>
|
|
595
|
+
|
|
596
|
+
{/* edit playlist button - toggles edit panel */}
|
|
597
|
+
<button
|
|
598
|
+
data-testid="btn-edit-playlist"
|
|
599
|
+
aria-expanded={editingPlaylist()}
|
|
600
|
+
onClick={() => {
|
|
601
|
+
if (showAllPlaylists()) setShowAllPlaylists(false);
|
|
602
|
+
if (showingShare()) closeShare();
|
|
603
|
+
editingPlaylist()
|
|
604
|
+
? handleCloseEdit()
|
|
605
|
+
: handleEditPlaylist();
|
|
606
|
+
}}
|
|
607
|
+
class={`p-2 hover:text-white hover:bg-gray-700 transition-colors bg-black/90 border ${editingPlaylist() ? "text-magenta-400 border-magenta-500" : "text-gray-400 border-transparent"}`}
|
|
608
|
+
title={
|
|
609
|
+
editingPlaylist() ? "close edit panel" : "edit playlist"
|
|
610
|
+
}
|
|
611
|
+
>
|
|
612
|
+
<svg
|
|
613
|
+
class="w-4 h-4"
|
|
614
|
+
fill="none"
|
|
615
|
+
stroke="currentColor"
|
|
616
|
+
viewBox="0 0 24 24"
|
|
617
|
+
>
|
|
618
|
+
<path
|
|
619
|
+
stroke-linecap="round"
|
|
620
|
+
stroke-linejoin="round"
|
|
621
|
+
stroke-width="2"
|
|
622
|
+
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"
|
|
623
|
+
/>
|
|
624
|
+
</svg>
|
|
625
|
+
</button>
|
|
626
|
+
|
|
627
|
+
{/* share playlist button: icon nodes fill based on connected
|
|
628
|
+
peer count (1/2/3+), pulse when transfers are active */}
|
|
629
|
+
<button
|
|
630
|
+
data-testid="btn-share-playlist"
|
|
631
|
+
aria-expanded={showingShare()}
|
|
632
|
+
onClick={() => {
|
|
633
|
+
if (showingShare()) {
|
|
634
|
+
closeShare();
|
|
635
|
+
} else {
|
|
636
|
+
if (showAllPlaylists()) setShowAllPlaylists(false);
|
|
637
|
+
if (editingPlaylist()) handleCloseEdit();
|
|
638
|
+
setShowingShare(true);
|
|
639
|
+
}
|
|
640
|
+
}}
|
|
641
|
+
class={`relative p-2 hover:text-white hover:bg-gray-700 transition-colors bg-black/90 border ${
|
|
642
|
+
showingShare()
|
|
643
|
+
? "text-magenta-400 border-magenta-500"
|
|
644
|
+
: sharingReady()
|
|
645
|
+
? "text-magenta-400 border-transparent"
|
|
646
|
+
: "text-gray-400 border-transparent"
|
|
647
|
+
}`}
|
|
648
|
+
title="share playlist"
|
|
649
|
+
>
|
|
650
|
+
<svg
|
|
651
|
+
class="w-4 h-4"
|
|
652
|
+
fill="none"
|
|
653
|
+
stroke="currentColor"
|
|
654
|
+
viewBox="0 0 24 24"
|
|
655
|
+
>
|
|
656
|
+
{/* connection lines */}
|
|
657
|
+
<line
|
|
658
|
+
x1="7"
|
|
659
|
+
y1="11.5"
|
|
660
|
+
x2="17"
|
|
661
|
+
y2="5.5"
|
|
662
|
+
stroke-width="1.5"
|
|
663
|
+
/>
|
|
664
|
+
<line
|
|
665
|
+
x1="7"
|
|
666
|
+
y1="12.5"
|
|
667
|
+
x2="17"
|
|
668
|
+
y2="18.5"
|
|
669
|
+
stroke-width="1.5"
|
|
670
|
+
/>
|
|
671
|
+
{/* left node - fills when 1+ connected */}
|
|
672
|
+
<circle
|
|
673
|
+
cx="5"
|
|
674
|
+
cy="12"
|
|
675
|
+
r="2.5"
|
|
676
|
+
stroke-width="1.5"
|
|
677
|
+
fill={connectedPeerCount() >= 1 ? "currentColor" : "none"}
|
|
678
|
+
class={
|
|
679
|
+
connectedPeerCount() >= 1 && isTransferring()
|
|
680
|
+
? "animate-pulse"
|
|
681
|
+
: ""
|
|
682
|
+
}
|
|
683
|
+
/>
|
|
684
|
+
{/* top-right node - fills when 2+ connected */}
|
|
685
|
+
<circle
|
|
686
|
+
cx="19"
|
|
687
|
+
cy="5"
|
|
688
|
+
r="2.5"
|
|
689
|
+
stroke-width="1.5"
|
|
690
|
+
fill={connectedPeerCount() >= 2 ? "currentColor" : "none"}
|
|
691
|
+
class={
|
|
692
|
+
connectedPeerCount() >= 2 && isTransferring()
|
|
693
|
+
? "animate-pulse"
|
|
694
|
+
: ""
|
|
695
|
+
}
|
|
696
|
+
/>
|
|
697
|
+
{/* bottom-right node - fills when 3+ connected */}
|
|
698
|
+
<circle
|
|
699
|
+
cx="19"
|
|
700
|
+
cy="19"
|
|
701
|
+
r="2.5"
|
|
702
|
+
stroke-width="1.5"
|
|
703
|
+
fill={connectedPeerCount() >= 3 ? "currentColor" : "none"}
|
|
704
|
+
class={
|
|
705
|
+
connectedPeerCount() >= 3 && isTransferring()
|
|
706
|
+
? "animate-pulse"
|
|
707
|
+
: ""
|
|
708
|
+
}
|
|
709
|
+
/>
|
|
710
|
+
</svg>
|
|
711
|
+
<Show
|
|
712
|
+
when={pendingKnockCount() > 0 || outboundPendingCount() > 0}
|
|
713
|
+
>
|
|
714
|
+
<span class="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] px-0.5 rounded-full bg-magenta-500 text-white text-[9px] leading-[14px] text-center font-bold">
|
|
715
|
+
{pendingKnockCount() + outboundPendingCount()}
|
|
716
|
+
</span>
|
|
717
|
+
</Show>
|
|
718
|
+
</button>
|
|
719
|
+
|
|
720
|
+
{/* save offline button */}
|
|
721
|
+
<Show
|
|
722
|
+
when={
|
|
723
|
+
window.STANDALONE_MODE &&
|
|
724
|
+
window.location.protocol !== "file:"
|
|
725
|
+
}
|
|
726
|
+
>
|
|
727
|
+
<Show when={!allSongsCached()}>
|
|
728
|
+
<button
|
|
729
|
+
data-testid="btn-cache-offline"
|
|
730
|
+
onClick={handleCachePlaylist}
|
|
731
|
+
disabled={isCaching() || playlistSongs().length === 0}
|
|
732
|
+
class="p-2 text-gray-400 hover:text-magenta-400 hover:bg-gray-700 transition-colors bg-black/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
733
|
+
title="download songz for offline use"
|
|
734
|
+
>
|
|
735
|
+
<Show
|
|
736
|
+
when={!isCaching()}
|
|
737
|
+
fallback={
|
|
738
|
+
<svg
|
|
739
|
+
class="w-4 h-4 animate-spin"
|
|
740
|
+
fill="none"
|
|
741
|
+
stroke="currentColor"
|
|
742
|
+
viewBox="0 0 24 24"
|
|
743
|
+
>
|
|
744
|
+
<path
|
|
745
|
+
stroke-linecap="round"
|
|
746
|
+
stroke-linejoin="round"
|
|
747
|
+
stroke-width="2"
|
|
748
|
+
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"
|
|
749
|
+
/>
|
|
750
|
+
</svg>
|
|
751
|
+
}
|
|
752
|
+
>
|
|
753
|
+
SAVE OFFLINE
|
|
754
|
+
</Show>
|
|
755
|
+
</button>
|
|
756
|
+
</Show>
|
|
757
|
+
</Show>
|
|
758
|
+
|
|
759
|
+
{/* share playlist (p2p) moved to the edit panel's share
|
|
760
|
+
column - no header share button */}
|
|
761
|
+
|
|
762
|
+
{/* p2p save offline button (fetch missing blobs from peers);
|
|
763
|
+
hidden once everything is already cached locally */}
|
|
764
|
+
<Show
|
|
765
|
+
when={
|
|
766
|
+
!window.STANDALONE_MODE && sharingReady() && p2pHasMissing()
|
|
767
|
+
}
|
|
768
|
+
>
|
|
769
|
+
<button
|
|
770
|
+
data-testid="btn-p2p-save-offline"
|
|
771
|
+
onClick={() => void handleP2pSaveOffline()}
|
|
772
|
+
disabled={p2pSaveProgress() !== null}
|
|
773
|
+
class="p-2 text-gray-400 hover:text-magenta-400 hover:bg-gray-700 transition-colors bg-black/90 disabled:opacity-50"
|
|
774
|
+
title={
|
|
775
|
+
p2pSaveProgress()
|
|
776
|
+
? `fetching ${p2pSaveProgress()!.currentTitle} (${p2pSaveProgress()!.done}/${p2pSaveProgress()!.total})`
|
|
777
|
+
: "save offline (fetch from peerz)"
|
|
778
|
+
}
|
|
779
|
+
>
|
|
780
|
+
<Show
|
|
781
|
+
when={!p2pSaveProgress()}
|
|
782
|
+
fallback={
|
|
783
|
+
<svg
|
|
784
|
+
class="w-4 h-4 animate-spin"
|
|
785
|
+
fill="none"
|
|
786
|
+
stroke="currentColor"
|
|
787
|
+
viewBox="0 0 24 24"
|
|
788
|
+
>
|
|
789
|
+
<path
|
|
790
|
+
stroke-linecap="round"
|
|
791
|
+
stroke-linejoin="round"
|
|
792
|
+
stroke-width="2"
|
|
793
|
+
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"
|
|
794
|
+
/>
|
|
795
|
+
</svg>
|
|
796
|
+
}
|
|
797
|
+
>
|
|
798
|
+
<svg
|
|
799
|
+
class="w-4 h-4"
|
|
800
|
+
fill="none"
|
|
801
|
+
stroke="currentColor"
|
|
802
|
+
viewBox="0 0 24 24"
|
|
803
|
+
>
|
|
804
|
+
<path
|
|
805
|
+
stroke-linecap="round"
|
|
806
|
+
stroke-linejoin="round"
|
|
807
|
+
stroke-width="2"
|
|
808
|
+
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
|
809
|
+
/>
|
|
810
|
+
</svg>
|
|
811
|
+
</Show>
|
|
812
|
+
</button>
|
|
813
|
+
</Show>
|
|
814
|
+
|
|
815
|
+
{/* add songs button: opens system file picker, same handler as drag-and-drop.
|
|
816
|
+
hidden in file:// mode (standalone zip) where new songs can't be added. */}
|
|
817
|
+
<Show when={window.location.protocol !== "file:"}>
|
|
818
|
+
<label
|
|
819
|
+
data-testid="btn-add-songs"
|
|
820
|
+
title="add songz"
|
|
821
|
+
class="p-2 text-gray-400 hover:text-green-400 hover:bg-gray-700 transition-colors bg-black/90 cursor-pointer"
|
|
822
|
+
>
|
|
823
|
+
<input
|
|
824
|
+
type="file"
|
|
825
|
+
accept="audio/*,.mp3,.wav,.flac,.ogg,.m4a,.aiff,.zip"
|
|
826
|
+
multiple
|
|
827
|
+
class="hidden"
|
|
828
|
+
onChange={async (e) => {
|
|
829
|
+
const files = Array.from(e.currentTarget.files ?? []);
|
|
830
|
+
if (!files.length) return;
|
|
831
|
+
await dragDrop.processFileImport(files, {
|
|
832
|
+
selectedPlaylist: props.playlist(),
|
|
833
|
+
playlists: playlistManager.playlists(),
|
|
834
|
+
onPlaylistSelected: (p) =>
|
|
835
|
+
playlistManager.selectPlaylist(p),
|
|
836
|
+
});
|
|
837
|
+
e.currentTarget.value = "";
|
|
838
|
+
}}
|
|
839
|
+
/>
|
|
840
|
+
<svg
|
|
841
|
+
class="w-4 h-4"
|
|
842
|
+
fill="none"
|
|
843
|
+
stroke="currentColor"
|
|
844
|
+
viewBox="0 0 24 24"
|
|
845
|
+
>
|
|
846
|
+
<path
|
|
847
|
+
stroke-linecap="round"
|
|
848
|
+
stroke-linejoin="round"
|
|
849
|
+
stroke-width="2.5"
|
|
850
|
+
d="M12 4v16m8-8H4"
|
|
851
|
+
/>
|
|
852
|
+
</svg>
|
|
853
|
+
</label>
|
|
854
|
+
</Show>
|
|
855
|
+
</div>
|
|
856
|
+
</div>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// desktop cover image (right side of the header)
|
|
861
|
+
function coverImage() {
|
|
862
|
+
return (
|
|
863
|
+
<div class={`${isMobile() ? "hidden" : "ml-4"}`}>
|
|
864
|
+
<button
|
|
865
|
+
onClick={() => {
|
|
866
|
+
openImageModal(props.playlist(), playlistSongs(), 0);
|
|
867
|
+
}}
|
|
868
|
+
class="w-39 h-39 overflow-hidden hover:bg-gray-900 flex items-center justify-center transition-colors group"
|
|
869
|
+
style={{
|
|
870
|
+
filter: (() => {
|
|
871
|
+
const p = props.playlist();
|
|
872
|
+
if (p.coverFilterEnabled === false) return "none";
|
|
873
|
+
const blur = p.coverFilterBlur ?? 3;
|
|
874
|
+
return `blur(${blur}px) contrast(3) brightness(0.4)`;
|
|
875
|
+
})(),
|
|
876
|
+
}}
|
|
877
|
+
onMouseEnter={(e) => (e.currentTarget.style.filter = "none")}
|
|
878
|
+
onMouseLeave={(e) => {
|
|
879
|
+
const p = props.playlist();
|
|
880
|
+
if (p.coverFilterEnabled === false) {
|
|
881
|
+
e.currentTarget.style.filter = "none";
|
|
882
|
+
} else {
|
|
883
|
+
const blur = p.coverFilterBlur ?? 3;
|
|
884
|
+
e.currentTarget.style.filter = `blur(${blur}px) contrast(3) brightness(0.4)`;
|
|
885
|
+
}
|
|
886
|
+
}}
|
|
887
|
+
title="view playlist imagez"
|
|
888
|
+
>
|
|
889
|
+
<Show
|
|
890
|
+
when={props.playlist().imageType}
|
|
891
|
+
fallback={
|
|
892
|
+
<div class="text-center">
|
|
893
|
+
<svg
|
|
894
|
+
width="100"
|
|
895
|
+
height="100"
|
|
896
|
+
viewBox="0 0 100 100"
|
|
897
|
+
fill="none"
|
|
898
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
899
|
+
>
|
|
900
|
+
<path
|
|
901
|
+
d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
|
|
902
|
+
fill="#FF00FF"
|
|
903
|
+
/>
|
|
904
|
+
</svg>
|
|
905
|
+
</div>
|
|
906
|
+
}
|
|
907
|
+
>
|
|
908
|
+
{(() => {
|
|
909
|
+
const imageUrl = getImageUrlForContext(
|
|
910
|
+
props.playlist(),
|
|
911
|
+
"modal"
|
|
912
|
+
);
|
|
913
|
+
return (
|
|
914
|
+
<>
|
|
915
|
+
{imageUrl ? (
|
|
916
|
+
<img
|
|
917
|
+
src={imageUrl}
|
|
918
|
+
alt="playlist cover"
|
|
919
|
+
class="w-full h-full object-cover"
|
|
920
|
+
/>
|
|
921
|
+
) : (
|
|
922
|
+
<div class="text-center">
|
|
923
|
+
<svg
|
|
924
|
+
width="100"
|
|
925
|
+
height="100"
|
|
926
|
+
viewBox="0 0 100 100"
|
|
927
|
+
fill="none"
|
|
928
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
929
|
+
>
|
|
930
|
+
<path
|
|
931
|
+
d="M50 81L25 31L75 31L60.7222 68.1429L50 81Z"
|
|
932
|
+
fill="#FF00FF"
|
|
933
|
+
/>
|
|
934
|
+
</svg>
|
|
935
|
+
</div>
|
|
936
|
+
)}
|
|
937
|
+
</>
|
|
938
|
+
);
|
|
939
|
+
})()}
|
|
940
|
+
</Show>
|
|
941
|
+
</button>
|
|
942
|
+
</div>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return (
|
|
947
|
+
<>
|
|
948
|
+
<Show when={!isMobile()}>{headerSection()}</Show>
|
|
949
|
+
|
|
950
|
+
{/* songz list and edit panels. on mobile the playlist header scrolls
|
|
951
|
+
away with the content while the player controls bar stays sticky */}
|
|
952
|
+
<div
|
|
953
|
+
ref={scrollContainerRef}
|
|
954
|
+
class="flex-1 overflow-y-auto min-h-0"
|
|
955
|
+
>
|
|
956
|
+
<Show when={isMobile()}>
|
|
957
|
+
{headerSection()}
|
|
958
|
+
<div
|
|
959
|
+
ref={stickyBarRef}
|
|
960
|
+
style={headerStyle()}
|
|
961
|
+
class="sticky top-0 z-[110] bg-black py-1"
|
|
962
|
+
>
|
|
963
|
+
{playerControls()}
|
|
964
|
+
</div>
|
|
965
|
+
</Show>
|
|
966
|
+
{/* inline share panel - renders once rows have animated out.
|
|
967
|
+
keyed on playlist id so it remounts when switching playlists */}
|
|
968
|
+
<Show
|
|
969
|
+
when={showingShare() && rowsGone() ? props.playlist().id : null}
|
|
970
|
+
keyed
|
|
971
|
+
>
|
|
972
|
+
<div style={panelEntryStyle()}>
|
|
973
|
+
<PlaylistSharePanel
|
|
974
|
+
playlist={props.playlist}
|
|
975
|
+
playlists={playlists()}
|
|
976
|
+
onClose={closeShare}
|
|
977
|
+
onPlaylistAdded={(docId) => {
|
|
978
|
+
playlistManager.selectById(docId);
|
|
979
|
+
closeShare();
|
|
980
|
+
}}
|
|
981
|
+
/>
|
|
982
|
+
</div>
|
|
983
|
+
</Show>
|
|
984
|
+
|
|
985
|
+
{/* inline all-playlists panel - same row-exit animation as edit mode.
|
|
986
|
+
the selected playlist row is not shown (it's the header above).
|
|
987
|
+
edit/share on other rows selects that playlist first. */}
|
|
988
|
+
<Show when={showAllPlaylists() && rowsGone()}>
|
|
989
|
+
<div style={panelEntryStyle()}>
|
|
990
|
+
<AllPlaylistsPanel
|
|
991
|
+
onClose={() => {
|
|
992
|
+
setShowAllPlaylists(false);
|
|
993
|
+
setAllPlaylistsPeerQuery(undefined);
|
|
994
|
+
}}
|
|
995
|
+
onEdit={(p) => {
|
|
996
|
+
playlistManager.selectPlaylist(p);
|
|
997
|
+
setShowAllPlaylists(false);
|
|
998
|
+
setAllPlaylistsPeerQuery(undefined);
|
|
999
|
+
setTimeout(() => handleEditPlaylist(), 0);
|
|
1000
|
+
}}
|
|
1001
|
+
onShare={(p) => {
|
|
1002
|
+
playlistManager.selectPlaylist(p);
|
|
1003
|
+
setShowAllPlaylists(false);
|
|
1004
|
+
setAllPlaylistsPeerQuery(undefined);
|
|
1005
|
+
setTimeout(() => setShowingShare(true), 0);
|
|
1006
|
+
}}
|
|
1007
|
+
onPlaylistAdded={(docId) => {
|
|
1008
|
+
playlistManager.selectById(docId);
|
|
1009
|
+
setShowAllPlaylists(false);
|
|
1010
|
+
setAllPlaylistsPeerQuery(undefined);
|
|
1011
|
+
}}
|
|
1012
|
+
initialQuery={allPlaylistsPeerQuery()}
|
|
1013
|
+
/>
|
|
1014
|
+
</div>
|
|
1015
|
+
</Show>
|
|
1016
|
+
|
|
1017
|
+
{/* inline playlist edit panel - only renders once rows have animated out.
|
|
1018
|
+
keyed on playlist id so the form remounts with fresh data when
|
|
1019
|
+
switching playlists via the sidebar */}
|
|
1020
|
+
<Show
|
|
1021
|
+
when={
|
|
1022
|
+
editingPlaylist() && rowsGone() ? props.playlist().id : null
|
|
1023
|
+
}
|
|
1024
|
+
keyed
|
|
1025
|
+
>
|
|
1026
|
+
<div
|
|
1027
|
+
style={panelEntryStyle()}
|
|
1028
|
+
class={isMobile() ? "p-2" : "px-6 pt-2 pb-4"}
|
|
1029
|
+
>
|
|
1030
|
+
<PlaylistEditPanel
|
|
1031
|
+
playlist={props.playlist()}
|
|
1032
|
+
playlistSongs={playlistSongs()}
|
|
1033
|
+
onClose={handleCloseEdit}
|
|
1034
|
+
onSave={(updated) =>
|
|
1035
|
+
playlistManager.selectPlaylist(updated)
|
|
1036
|
+
}
|
|
1037
|
+
onFork={(newDocId) => {
|
|
1038
|
+
playlistManager.selectById(newDocId);
|
|
1039
|
+
handleCloseEdit();
|
|
1040
|
+
}}
|
|
1041
|
+
/>
|
|
1042
|
+
</div>
|
|
1043
|
+
</Show>
|
|
1044
|
+
|
|
1045
|
+
{/* inline song edit panel - only renders once rows have animated out.
|
|
1046
|
+
keyed on song id so the form remounts when navigating between songs */}
|
|
1047
|
+
<Show
|
|
1048
|
+
when={editingSong() && rowsGone() ? editingSong()!.id : null}
|
|
1049
|
+
keyed
|
|
1050
|
+
>
|
|
1051
|
+
<div
|
|
1052
|
+
style={panelEntryStyle()}
|
|
1053
|
+
class={isMobile() ? "" : "px-6 pt-2 pb-4"}
|
|
1054
|
+
>
|
|
1055
|
+
<SongEditPanel
|
|
1056
|
+
song={editingSong()!}
|
|
1057
|
+
index={editingSongIndex()}
|
|
1058
|
+
onClose={handleCloseEdit}
|
|
1059
|
+
onSave={handleSongSaved}
|
|
1060
|
+
prevSong={songAtOffset(-1)}
|
|
1061
|
+
nextSong={songAtOffset(1)}
|
|
1062
|
+
onNavigate={handleEditSong}
|
|
1063
|
+
/>
|
|
1064
|
+
</div>
|
|
1065
|
+
</Show>
|
|
1066
|
+
|
|
1067
|
+
{/* rows container - no overflow:hidden here; scroll container clips instead.
|
|
1068
|
+
fully removed from layout once rows are gone so leftover padding +
|
|
1069
|
+
space-y margins don't add phantom height below the edit panel */}
|
|
1070
|
+
<div
|
|
1071
|
+
class={`${isMobile() ? "space-y-1" : "p-6 space-y-2"}`}
|
|
1072
|
+
style={rowsGone() ? { display: "none" } : {}}
|
|
1073
|
+
>
|
|
1074
|
+
{/* empty playlist message - hidden during edit mode */}
|
|
1075
|
+
<Show
|
|
1076
|
+
when={
|
|
1077
|
+
!isEditing() &&
|
|
1078
|
+
(!props.playlist().songIds ||
|
|
1079
|
+
props.playlist().songIds.length === 0)
|
|
1080
|
+
}
|
|
1081
|
+
>
|
|
1082
|
+
<div
|
|
1083
|
+
data-testid="empty-songs"
|
|
1084
|
+
class={`${isMobile() ? "" : "ml-42 mr-42"} text-center p-8 bg-black/75`}
|
|
1085
|
+
>
|
|
1086
|
+
<div class="text-gray-400 text-xl mb-4">no songz yet</div>
|
|
1087
|
+
<p class="text-gray-400 mb-4">
|
|
1088
|
+
drag and drop audio filez (or a .zip file!) here to add
|
|
1089
|
+
them to this playlist
|
|
1090
|
+
</p>
|
|
1091
|
+
<div class="text-xs text-gray-500 space-y-1">
|
|
1092
|
+
<div>playlist id: {props.playlist().id}</div>
|
|
1093
|
+
<div>
|
|
1094
|
+
supported formatz: mp3, wav, flac, aiff, ogg, mp4
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
1097
|
+
</div>
|
|
1098
|
+
</Show>
|
|
1099
|
+
|
|
1100
|
+
{/* animated song rows: outer wrapper collapses height after animation,
|
|
1101
|
+
inner wrapper runs the CSS keyframe flyout/flyin animation */}
|
|
1102
|
+
<For each={props.playlist().songIds}>
|
|
1103
|
+
{(songId, index) => {
|
|
1104
|
+
const isBeingEdited = () => editingSong()?.id === songId;
|
|
1105
|
+
// sticky has to live on this outer wrapper: the row itself is
|
|
1106
|
+
// boxed in by the animation wrappers, so position: sticky on it
|
|
1107
|
+
// can't escape and never actually sticks
|
|
1108
|
+
const isActiveRow = () =>
|
|
1109
|
+
audioState.selectedSongId() === songId ||
|
|
1110
|
+
(audioState.currentSong()?.id === songId &&
|
|
1111
|
+
audioState.isPlaying());
|
|
1112
|
+
return (
|
|
1113
|
+
<Show when={!isBeingEdited()}>
|
|
1114
|
+
<div
|
|
1115
|
+
style={{
|
|
1116
|
+
...rowOuterStyle(),
|
|
1117
|
+
...(isActiveRow()
|
|
1118
|
+
? { top: `${stickyBarHeight()}px` }
|
|
1119
|
+
: {}),
|
|
1120
|
+
}}
|
|
1121
|
+
class={isActiveRow() ? "sticky bottom-0 z-100" : ""}
|
|
1122
|
+
>
|
|
1123
|
+
<div style={rowInnerStyle(index())}>
|
|
1124
|
+
<SongRow
|
|
1125
|
+
songId={songId}
|
|
1126
|
+
index={index()}
|
|
1127
|
+
showRemoveButton={!isSubscribed()}
|
|
1128
|
+
onRemove={handleRemoveSong}
|
|
1129
|
+
onPlay={handlePlaySongWithPlaylist}
|
|
1130
|
+
onPause={handlePauseSong}
|
|
1131
|
+
onEdit={handleEditSong}
|
|
1132
|
+
onReorder={
|
|
1133
|
+
isSubscribed() ? undefined : handleReorderSongs
|
|
1134
|
+
}
|
|
1135
|
+
/>
|
|
1136
|
+
</div>
|
|
1137
|
+
</div>
|
|
1138
|
+
</Show>
|
|
1139
|
+
);
|
|
1140
|
+
}}
|
|
1141
|
+
</For>
|
|
1142
|
+
</div>
|
|
1143
|
+
</div>
|
|
1144
|
+
</>
|
|
1145
|
+
);
|
|
1146
|
+
})()}
|
|
1147
|
+
</div>
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// compact read-only banner shown below the title/description for subscribed playlists.
|
|
1152
|
+
// provides quick access to fork (local copy) and the full edit panel (for collab request).
|
|
1153
|
+
function SubscribedBanner(props: {
|
|
1154
|
+
playlist: Playlist;
|
|
1155
|
+
onFork: (newDocId: string) => void;
|
|
1156
|
+
}) {
|
|
1157
|
+
const [forking, setForking] = createSignal(false);
|
|
1158
|
+
const [forkError, setForkError] = createSignal<string | null>(null);
|
|
1159
|
+
|
|
1160
|
+
const handleFork = async () => {
|
|
1161
|
+
if (forking()) return;
|
|
1162
|
+
setForking(true);
|
|
1163
|
+
setForkError(null);
|
|
1164
|
+
try {
|
|
1165
|
+
const forked = await forkPlaylist(props.playlist.id);
|
|
1166
|
+
props.onFork(forked.id);
|
|
1167
|
+
} catch (err) {
|
|
1168
|
+
setForkError("fork failed");
|
|
1169
|
+
console.error("fork error:", err);
|
|
1170
|
+
} finally {
|
|
1171
|
+
setForking(false);
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
|
|
1175
|
+
const displayName = () =>
|
|
1176
|
+
props.playlist.remoteName ||
|
|
1177
|
+
props.playlist.remoteNodeId?.slice(0, 16) ||
|
|
1178
|
+
"peer";
|
|
1179
|
+
|
|
1180
|
+
return (
|
|
1181
|
+
<div
|
|
1182
|
+
data-testid="subscribed-banner"
|
|
1183
|
+
class="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1.5 bg-black/70 border-t border-gray-800 text-xs"
|
|
1184
|
+
>
|
|
1185
|
+
<span class="text-yellow-500/80 font-medium">read only</span>
|
|
1186
|
+
<span class="text-gray-600">·</span>
|
|
1187
|
+
<span class="text-gray-500">from {displayName()}</span>
|
|
1188
|
+
<div class="flex items-center gap-2 ml-auto">
|
|
1189
|
+
<button
|
|
1190
|
+
data-testid="btn-fork-playlist-banner"
|
|
1191
|
+
class="px-2 py-0.5 text-gray-300 hover:text-white border border-gray-700 hover:border-gray-500 disabled:opacity-50 transition-colors"
|
|
1192
|
+
onClick={() => void handleFork()}
|
|
1193
|
+
disabled={forking()}
|
|
1194
|
+
>
|
|
1195
|
+
{forking() ? "forking..." : "fork my copy"}
|
|
1196
|
+
</button>
|
|
1197
|
+
</div>
|
|
1198
|
+
<Show when={forkError()}>
|
|
1199
|
+
<span class="w-full text-red-400">{forkError()}</span>
|
|
1200
|
+
</Show>
|
|
1201
|
+
</div>
|
|
1202
|
+
);
|
|
1203
|
+
}
|