@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,685 @@
|
|
|
1
|
+
|
|
2
|
+
import {
|
|
3
|
+
createSignal,
|
|
4
|
+
createEffect,
|
|
5
|
+
createMemo,
|
|
6
|
+
on,
|
|
7
|
+
onMount,
|
|
8
|
+
onCleanup,
|
|
9
|
+
} from "solid-js";
|
|
10
|
+
import type { Playlist, Song } from "../types/playlist.js";
|
|
11
|
+
import { createDocIndexQuery } from "./createDocIndexQuery.js";
|
|
12
|
+
import {
|
|
13
|
+
createPlaylist,
|
|
14
|
+
updatePlaylist,
|
|
15
|
+
deletePlaylist,
|
|
16
|
+
addSongToPlaylist,
|
|
17
|
+
deleteSong,
|
|
18
|
+
reorderSongsInDoc,
|
|
19
|
+
getSongsFromHandle,
|
|
20
|
+
docToPlaylistAsync,
|
|
21
|
+
} from "../services/playlistDocService.js";
|
|
22
|
+
import { findPlaylistDoc } from "../services/automergeRepo.js";
|
|
23
|
+
import { parsePlaylistDoc } from "@freqhole/api-client/playlistz";
|
|
24
|
+
import {
|
|
25
|
+
refreshPlaylistQueue,
|
|
26
|
+
audioState,
|
|
27
|
+
stop,
|
|
28
|
+
} from "../services/audioService.js";
|
|
29
|
+
import { filterAudioFiles } from "../services/fileProcessingService.js";
|
|
30
|
+
import { log } from "../utils/log.js";
|
|
31
|
+
import {
|
|
32
|
+
parsePlaylistZip,
|
|
33
|
+
downloadPlaylistAsZip,
|
|
34
|
+
} from "../services/playlistDownloadService.js";
|
|
35
|
+
import {
|
|
36
|
+
cacheAudioFile,
|
|
37
|
+
initializeOfflineSupport,
|
|
38
|
+
updatePWAManifest,
|
|
39
|
+
} from "../services/offlineService.js";
|
|
40
|
+
import {
|
|
41
|
+
initializeAllStandalonePlaylists,
|
|
42
|
+
clearStandaloneLoadingProgress,
|
|
43
|
+
enrichSongsWithStandalonePaths,
|
|
44
|
+
enrichPlaylistWithStandalonePaths,
|
|
45
|
+
standalonePreferredDocId,
|
|
46
|
+
setStandalonePreferredDocId,
|
|
47
|
+
} from "../services/standaloneService.js";
|
|
48
|
+
import { getImageUrlForContext } from "../services/imageService.js";
|
|
49
|
+
import type { AutomergeUrl } from "@automerge/automerge-repo";
|
|
50
|
+
import type { DocIndexEntry } from "../services/indexedDBService.js";
|
|
51
|
+
import { saveSetting, loadSetting } from "../services/indexedDBService.js";
|
|
52
|
+
|
|
53
|
+
const SETTING_SELECTED_PLAYLIST = "selectedPlaylistId";
|
|
54
|
+
|
|
55
|
+
export function usePlaylistManager() {
|
|
56
|
+
const [playlists, setPlaylists] = createSignal<Playlist[]>([]);
|
|
57
|
+
const [selectedPlaylistId, setSelectedPlaylistId] = createSignal<string | null>(null);
|
|
58
|
+
// derived: always reflects the current version from playlists(), so stale
|
|
59
|
+
// playlist objects from before songs/edits can never overwrite fresh state.
|
|
60
|
+
const selectedPlaylist = createMemo(
|
|
61
|
+
() => playlists().find((p) => p.id === selectedPlaylistId()) ?? null
|
|
62
|
+
);
|
|
63
|
+
// compat wrapper: also upserts the playlist into playlists() if not present
|
|
64
|
+
// (needed for standalone mode which sets selection before the docIndex syncs)
|
|
65
|
+
const setSelectedPlaylist = (p: Playlist | null) => {
|
|
66
|
+
if (p) setPlaylists((prev) => (prev.some((pl) => pl.id === p.id) ? prev : [...prev, p]));
|
|
67
|
+
setSelectedPlaylistId(p?.id ?? null);
|
|
68
|
+
};
|
|
69
|
+
const [playlistSongs, setPlaylistSongs] = createSignal<Song[]>([]);
|
|
70
|
+
const [isInitialized, setIsInitialized] = createSignal(false);
|
|
71
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
72
|
+
|
|
73
|
+
// persist selection to idb whenever it changes so it survives a reload.
|
|
74
|
+
// the effect only runs after the signal has a non-null value so we don't
|
|
75
|
+
// overwrite a saved selection with null during the initial startup sync.
|
|
76
|
+
createEffect(() => {
|
|
77
|
+
const id = selectedPlaylistId();
|
|
78
|
+
if (id) void saveSetting(SETTING_SELECTED_PLAYLIST, id);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// modal and UI state
|
|
82
|
+
const [showImageModal, setShowImageModal] = createSignal(false);
|
|
83
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = createSignal(false);
|
|
84
|
+
const [modalImageIndex, setModalImageIndex] = createSignal(0);
|
|
85
|
+
|
|
86
|
+
// loading and operation state
|
|
87
|
+
const [isDownloading, setIsDownloading] = createSignal(false);
|
|
88
|
+
const [isCaching, setIsCaching] = createSignal(false);
|
|
89
|
+
const [allSongsCached, setAllSongsCached] = createSignal(false);
|
|
90
|
+
|
|
91
|
+
const [backgroundImageUrl, setBackgroundImageUrl] = createSignal<
|
|
92
|
+
string | null
|
|
93
|
+
>(null);
|
|
94
|
+
const [imageUrlCache] = createSignal(new Map<string, string>());
|
|
95
|
+
|
|
96
|
+
const [backgroundOverride, setBackgroundOverride] = createSignal<
|
|
97
|
+
Song | "cover" | null
|
|
98
|
+
>(null);
|
|
99
|
+
|
|
100
|
+
const [backgroundSource, setBackgroundSource] = createSignal<string | null>(
|
|
101
|
+
null
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// live docIndex query - drives sidebar
|
|
105
|
+
const docIndexEntries = createDocIndexQuery();
|
|
106
|
+
|
|
107
|
+
// unsubscribe fn for the selected playlist's doc change listener
|
|
108
|
+
let docStoreCleanup: (() => void) | null = null;
|
|
109
|
+
|
|
110
|
+
// convert docIndex entries to Playlist view objects and update signal
|
|
111
|
+
let _syncCalls = 0;
|
|
112
|
+
async function syncPlaylistsFromDocIndex(
|
|
113
|
+
entries: DocIndexEntry[]
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
_syncCalls++;
|
|
116
|
+
const syncId = _syncCalls;
|
|
117
|
+
log.debug("playlist.sync", "syncPlaylists #", String(syncId), "entries:", String(entries.length));
|
|
118
|
+
try {
|
|
119
|
+
const resolved = await Promise.all(
|
|
120
|
+
entries.map(async (entry) => {
|
|
121
|
+
try {
|
|
122
|
+
const handle = await findPlaylistDoc(
|
|
123
|
+
entry.docId as AutomergeUrl
|
|
124
|
+
);
|
|
125
|
+
const raw = handle.doc();
|
|
126
|
+
const doc = parsePlaylistDoc(raw ?? {});
|
|
127
|
+
const playlist = await docToPlaylistAsync(entry.docId, doc);
|
|
128
|
+
// overlay docIndex remote-source metadata
|
|
129
|
+
playlist.remoteNodeId = entry.remoteNodeId;
|
|
130
|
+
playlist.remoteName = entry.remoteName;
|
|
131
|
+
playlist.remoteAvatarDataUrl = entry.remoteAvatarDataUrl;
|
|
132
|
+
playlist.isForked = entry.isForked;
|
|
133
|
+
return playlist;
|
|
134
|
+
} catch {
|
|
135
|
+
// doc not yet available - use entry metadata as placeholder
|
|
136
|
+
return {
|
|
137
|
+
id: entry.docId,
|
|
138
|
+
title: entry.title,
|
|
139
|
+
description: undefined,
|
|
140
|
+
createdAt: entry.addedAt,
|
|
141
|
+
updatedAt: entry.addedAt,
|
|
142
|
+
songIds: [],
|
|
143
|
+
remoteNodeId: entry.remoteNodeId,
|
|
144
|
+
remoteName: entry.remoteName,
|
|
145
|
+
remoteAvatarDataUrl: entry.remoteAvatarDataUrl,
|
|
146
|
+
isForked: entry.isForked,
|
|
147
|
+
} as Playlist;
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
log.debug("playlist.sync", "syncPlaylists #", String(syncId), "resolved", String(resolved.length));
|
|
153
|
+
setPlaylists(resolved.map(enrichPlaylistWithStandalonePaths));
|
|
154
|
+
|
|
155
|
+
// update selection id only: selectedPlaylist() will auto-derive from playlists()
|
|
156
|
+
const currentId = selectedPlaylistId();
|
|
157
|
+
if (currentId) {
|
|
158
|
+
const stillExists = resolved.some((p) => p.id === currentId);
|
|
159
|
+
if (!stillExists) {
|
|
160
|
+
setSelectedPlaylistId(resolved.length > 0 ? resolved[0]!.id : null);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
// no in-memory selection yet - try to restore from idb, fall back to first
|
|
164
|
+
const savedId = await loadSetting<string>(SETTING_SELECTED_PLAYLIST);
|
|
165
|
+
const target = savedId ? resolved.find((p) => p.id === savedId) : null;
|
|
166
|
+
setSelectedPlaylistId(target ? target.id : resolved.length > 0 ? resolved[0]!.id : null);
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
log.error("playlist.sync", "error syncing playlists from doc index:", err);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const initialize = async () => {
|
|
174
|
+
try {
|
|
175
|
+
setError(null);
|
|
176
|
+
|
|
177
|
+
// check to init standalone mode
|
|
178
|
+
if (window.STANDALONE_MODE) {
|
|
179
|
+
await initializeOfflineSupport();
|
|
180
|
+
await updatePWAManifest("Playlistz", undefined);
|
|
181
|
+
|
|
182
|
+
const deferredData = window.DEFERRED_PLAYLIST_DATA;
|
|
183
|
+
if (deferredData && deferredData.length > 0) {
|
|
184
|
+
try {
|
|
185
|
+
await initializeAllStandalonePlaylists(deferredData, {
|
|
186
|
+
setSelectedPlaylist,
|
|
187
|
+
setPlaylistSongs,
|
|
188
|
+
setSidebarCollapsed: () => {},
|
|
189
|
+
setError,
|
|
190
|
+
});
|
|
191
|
+
delete window.DEFERRED_PLAYLIST_DATA;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
log.error("playlist.init", "error initializing deferred playlist:", err);
|
|
194
|
+
setError("failed to initialize playlist!");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
clearStandaloneLoadingProgress();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await initializeOfflineSupport();
|
|
203
|
+
} catch (offlineError) {
|
|
204
|
+
log.warn("playlist.init", "offline support initialization failed:", offlineError);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setIsInitialized(true);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.error("playlist.init", "error initializing playlist manager:", err);
|
|
210
|
+
setError("failed to initialize playlist");
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const createNewPlaylist = async (title: string = "new playlist") => {
|
|
215
|
+
try {
|
|
216
|
+
setError(null);
|
|
217
|
+
const playlist = await createPlaylist({ title, description: "" });
|
|
218
|
+
return playlist;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
log.error("playlist.create", "error creating playlist:", err);
|
|
221
|
+
setError("failed to create new playlist!");
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleFileDrop = async (files: FileList, targetPlaylistId?: string) => {
|
|
227
|
+
try {
|
|
228
|
+
setError(null);
|
|
229
|
+
|
|
230
|
+
if (files.length === 1 && files[0]?.name.toLowerCase().endsWith(".zip")) {
|
|
231
|
+
const zipFile = files[0];
|
|
232
|
+
const result = await parsePlaylistZip(zipFile);
|
|
233
|
+
return result.playlist;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const audioFiles = filterAudioFiles(Array.from(files));
|
|
237
|
+
if (audioFiles.length === 0) {
|
|
238
|
+
setError("no audio filez found!");
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let playlistId = targetPlaylistId;
|
|
243
|
+
if (!playlistId) {
|
|
244
|
+
const newPlaylist = await createNewPlaylist("dropped filez");
|
|
245
|
+
if (!newPlaylist) return null;
|
|
246
|
+
playlistId = newPlaylist.id;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const audioFile of audioFiles) {
|
|
250
|
+
await addSongToPlaylist(playlistId, audioFile);
|
|
251
|
+
}
|
|
252
|
+
// doc-change events from addSongToPlaylist -> flushDoc drive the refresh
|
|
253
|
+
|
|
254
|
+
return playlistId;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
log.error("playlist.drop", "error handling file drop:", err);
|
|
257
|
+
setError("failed to process dropped files");
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// reactive effect: when docIndex changes, refresh the playlists list
|
|
263
|
+
createEffect(() => {
|
|
264
|
+
const entries = docIndexEntries();
|
|
265
|
+
log.debug("playlist.docindex", "docIndex effect fired, entries:", String(entries.length));
|
|
266
|
+
void syncPlaylistsFromDocIndex(entries);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// reactive effect: when a standalone playlist is initialized, select it immediately.
|
|
270
|
+
// this overrides any previously remembered selection so the current zip's
|
|
271
|
+
// playlist is always shown first when opening a standalone file:// page.
|
|
272
|
+
// clears standalonePreferredDocId after applying so subsequent playlists()
|
|
273
|
+
// updates (doc changes, sidebar refreshes) don't keep snapping the selection back.
|
|
274
|
+
createEffect(() => {
|
|
275
|
+
const preferred = standalonePreferredDocId();
|
|
276
|
+
if (!preferred) return;
|
|
277
|
+
if (playlists().some((p) => p.id === preferred)) {
|
|
278
|
+
setSelectedPlaylistId(preferred);
|
|
279
|
+
setStandalonePreferredDocId(null);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// reactive effect (keyed by playlist id): subscribe to the selected playlist's
|
|
284
|
+
// doc handle so any mutation (adding songs, edits, remote sync) refreshes
|
|
285
|
+
// the songs list and updates the playlist entry in playlists().
|
|
286
|
+
// keyed on selectedPlaylistId so the effect only re-runs when the id changes.
|
|
287
|
+
createEffect(
|
|
288
|
+
on(
|
|
289
|
+
selectedPlaylistId,
|
|
290
|
+
(playlistId) => {
|
|
291
|
+
log.debug("playlist.select", "selection effect fired:", playlistId ?? "null");
|
|
292
|
+
if (docStoreCleanup) {
|
|
293
|
+
docStoreCleanup();
|
|
294
|
+
docStoreCleanup = null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!playlistId) {
|
|
298
|
+
setPlaylistSongs([]);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let disposed = false;
|
|
303
|
+
|
|
304
|
+
let _refreshCount = 0;
|
|
305
|
+
const refresh = async (
|
|
306
|
+
handle: Awaited<ReturnType<typeof findPlaylistDoc>>
|
|
307
|
+
) => {
|
|
308
|
+
_refreshCount++;
|
|
309
|
+
log.debug("playlist.select", "selected-doc refresh #", String(_refreshCount), playlistId);
|
|
310
|
+
try {
|
|
311
|
+
const raw = handle.doc();
|
|
312
|
+
const doc = parsePlaylistDoc(raw ?? {});
|
|
313
|
+
const updated = await docToPlaylistAsync(playlistId, doc);
|
|
314
|
+
|
|
315
|
+
setPlaylists((prev) =>
|
|
316
|
+
prev.map((p) => {
|
|
317
|
+
if (p.id !== playlistId) return p;
|
|
318
|
+
return enrichPlaylistWithStandalonePaths({
|
|
319
|
+
...updated,
|
|
320
|
+
remoteNodeId: p.remoteNodeId,
|
|
321
|
+
remoteName: p.remoteName,
|
|
322
|
+
remoteAvatarDataUrl: p.remoteAvatarDataUrl,
|
|
323
|
+
isForked: p.isForked,
|
|
324
|
+
});
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
// selectedPlaylist() auto-updates from playlists() via memo - no setSelectedPlaylist needed
|
|
328
|
+
|
|
329
|
+
// use the handle we already have - avoids a redundant repo.find()
|
|
330
|
+
const songs = await getSongsFromHandle(playlistId, handle);
|
|
331
|
+
if (!disposed) {
|
|
332
|
+
setPlaylistSongs(enrichSongsWithStandalonePaths(songs));
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
log.error("playlist.select", "error refreshing selected playlist doc:", err);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
void (async () => {
|
|
340
|
+
try {
|
|
341
|
+
const handle = await findPlaylistDoc(playlistId as AutomergeUrl);
|
|
342
|
+
if (disposed) return;
|
|
343
|
+
|
|
344
|
+
const onChange = () => {
|
|
345
|
+
log.debug("playlist.select", "selected-doc change event -> refresh", playlistId);
|
|
346
|
+
void refresh(handle);
|
|
347
|
+
};
|
|
348
|
+
handle.on("change", onChange);
|
|
349
|
+
docStoreCleanup = () => handle.off("change", onChange);
|
|
350
|
+
|
|
351
|
+
await refresh(handle);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
log.error("playlist.select", "error subscribing to playlist doc:", err);
|
|
354
|
+
if (!disposed) {
|
|
355
|
+
setPlaylistSongs([]);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
})();
|
|
359
|
+
|
|
360
|
+
onCleanup(() => {
|
|
361
|
+
disposed = true;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// update background image based on override, currently playing song, or selected playlist
|
|
368
|
+
createEffect(() => {
|
|
369
|
+
const override = backgroundOverride();
|
|
370
|
+
const currentSong = audioState.currentSong();
|
|
371
|
+
const currentPlaylist = audioState.currentPlaylist();
|
|
372
|
+
const selectedPl = selectedPlaylist();
|
|
373
|
+
const cache = imageUrlCache();
|
|
374
|
+
|
|
375
|
+
let newImageUrl: string | null = null;
|
|
376
|
+
let cacheKey: string | null = null;
|
|
377
|
+
|
|
378
|
+
if (override && override !== "cover" && override.imageType) {
|
|
379
|
+
cacheKey = `song-${override.id}`;
|
|
380
|
+
if (cache.has(cacheKey)) {
|
|
381
|
+
newImageUrl = cache.get(cacheKey)!;
|
|
382
|
+
} else {
|
|
383
|
+
newImageUrl = getImageUrlForContext(override, "background");
|
|
384
|
+
if (newImageUrl) {
|
|
385
|
+
cache.set(cacheKey, newImageUrl);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
} else if (override === "cover" && selectedPl?.imageType) {
|
|
389
|
+
cacheKey = `playlist-${selectedPl.id}`;
|
|
390
|
+
if (cache.has(cacheKey)) {
|
|
391
|
+
newImageUrl = cache.get(cacheKey)!;
|
|
392
|
+
} else {
|
|
393
|
+
newImageUrl = getImageUrlForContext(selectedPl, "background");
|
|
394
|
+
if (newImageUrl) {
|
|
395
|
+
cache.set(cacheKey, newImageUrl);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} else if (currentSong?.imageType) {
|
|
399
|
+
cacheKey = `song-${currentSong.id}`;
|
|
400
|
+
if (cache.has(cacheKey)) {
|
|
401
|
+
newImageUrl = cache.get(cacheKey)!;
|
|
402
|
+
} else {
|
|
403
|
+
newImageUrl = getImageUrlForContext(currentSong, "background");
|
|
404
|
+
if (newImageUrl) {
|
|
405
|
+
cache.set(cacheKey, newImageUrl);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} else if (currentSong && currentPlaylist?.imageType) {
|
|
409
|
+
cacheKey = `playlist-${currentPlaylist.id}`;
|
|
410
|
+
if (cache.has(cacheKey)) {
|
|
411
|
+
newImageUrl = cache.get(cacheKey)!;
|
|
412
|
+
} else {
|
|
413
|
+
newImageUrl = getImageUrlForContext(currentPlaylist, "background");
|
|
414
|
+
if (newImageUrl) {
|
|
415
|
+
cache.set(cacheKey, newImageUrl);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} else if (selectedPl?.imageType) {
|
|
419
|
+
cacheKey = `playlist-${selectedPl.id}`;
|
|
420
|
+
if (cache.has(cacheKey)) {
|
|
421
|
+
newImageUrl = cache.get(cacheKey)!;
|
|
422
|
+
} else {
|
|
423
|
+
newImageUrl = getImageUrlForContext(selectedPl, "background");
|
|
424
|
+
if (newImageUrl) {
|
|
425
|
+
cache.set(cacheKey, newImageUrl);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const prevUrl = backgroundImageUrl();
|
|
431
|
+
if (prevUrl !== newImageUrl) {
|
|
432
|
+
setBackgroundImageUrl(newImageUrl);
|
|
433
|
+
}
|
|
434
|
+
setBackgroundSource(cacheKey);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// update PWA manifest when playlist changes
|
|
438
|
+
createEffect(() => {
|
|
439
|
+
const playlist = selectedPlaylist();
|
|
440
|
+
if (playlist) {
|
|
441
|
+
log.debug("playlist.manifest", "PWA manifest effect fired", playlist.id);
|
|
442
|
+
updatePWAManifest(playlist.title, playlist);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const getPlaylistById = (id: string): Playlist | undefined => {
|
|
447
|
+
return playlists().find((p) => p.id === id);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const playlistExists = (id: string): boolean => {
|
|
451
|
+
return playlists().some((p) => p.id === id);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const getPlaylistCount = (): number => {
|
|
455
|
+
return playlists().length;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const searchPlaylists = (query: string): Playlist[] => {
|
|
459
|
+
if (!query.trim()) return playlists();
|
|
460
|
+
const lowercaseQuery = query.toLowerCase();
|
|
461
|
+
return playlists().filter(
|
|
462
|
+
(playlist) =>
|
|
463
|
+
playlist.title.toLowerCase().includes(lowercaseQuery) ||
|
|
464
|
+
(playlist.description || "").toLowerCase().includes(lowercaseQuery)
|
|
465
|
+
);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const selectPlaylist = (playlist: Playlist | null) => {
|
|
469
|
+
setSelectedPlaylistId(playlist?.id ?? null);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const selectById = (id: string) => {
|
|
473
|
+
setSelectedPlaylistId(id);
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const handlePlaylistUpdate = async (updates: Partial<Playlist>) => {
|
|
477
|
+
const playlist = selectedPlaylist();
|
|
478
|
+
if (!playlist) return;
|
|
479
|
+
|
|
480
|
+
log.debug("playlist.update", "handlePlaylistUpdate", playlist.id, JSON.stringify(updates));
|
|
481
|
+
try {
|
|
482
|
+
setError(null);
|
|
483
|
+
await updatePlaylist(playlist.id, {
|
|
484
|
+
title: updates.title,
|
|
485
|
+
description: updates.description,
|
|
486
|
+
});
|
|
487
|
+
// reactive query will refresh from docIndex
|
|
488
|
+
} catch (err) {
|
|
489
|
+
log.error("playlist.update", "error updating playlist:", err);
|
|
490
|
+
setError("failed to update playlist!");
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const handleDeletePlaylist = async () => {
|
|
495
|
+
const playlist = selectedPlaylist();
|
|
496
|
+
if (!playlist) return;
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
setError(null);
|
|
500
|
+
|
|
501
|
+
const currentSong = audioState.currentSong();
|
|
502
|
+
if (currentSong && currentSong.playlistId === playlist.id) {
|
|
503
|
+
stop();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
await deletePlaylist(playlist.id);
|
|
507
|
+
setSelectedPlaylist(null);
|
|
508
|
+
setShowDeleteConfirm(false);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
log.error("playlist.delete", "error deleting playlist:", err);
|
|
511
|
+
setError("failed to delete playlist!");
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const handleDownloadPlaylist = async () => {
|
|
516
|
+
const playlist = selectedPlaylist();
|
|
517
|
+
if (!playlist) return;
|
|
518
|
+
|
|
519
|
+
setIsDownloading(true);
|
|
520
|
+
try {
|
|
521
|
+
setError(null);
|
|
522
|
+
await downloadPlaylistAsZip(playlist, {
|
|
523
|
+
includeImages: true,
|
|
524
|
+
generateM3U: true,
|
|
525
|
+
includeHTML: true,
|
|
526
|
+
});
|
|
527
|
+
} catch (err) {
|
|
528
|
+
log.error("playlist.download", "error downloading playlist:", err);
|
|
529
|
+
setError("failed to download playlist!");
|
|
530
|
+
} finally {
|
|
531
|
+
setIsDownloading(false);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const handleRemoveSong = async (songId: string, onClose?: () => void) => {
|
|
536
|
+
const playlist = selectedPlaylist();
|
|
537
|
+
if (!playlist) return;
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
setError(null);
|
|
541
|
+
|
|
542
|
+
const currentSong = audioState.currentSong();
|
|
543
|
+
if (currentSong && currentSong.id === songId) {
|
|
544
|
+
stop();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
await deleteSong(playlist.id, songId);
|
|
548
|
+
// doc-change event fires from deleteSong -> flushDoc, driving refresh
|
|
549
|
+
|
|
550
|
+
if (onClose) {
|
|
551
|
+
onClose();
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
log.error("playlist.songs", "error removing song from playlist:", err);
|
|
555
|
+
setError("failed to remove song from playlist!");
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const handleReorderSongs = async (oldIndex: number, newIndex: number) => {
|
|
560
|
+
const playlist = selectedPlaylist();
|
|
561
|
+
if (!playlist) return;
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
setError(null);
|
|
565
|
+
await reorderSongsInDoc(playlist.id, oldIndex, newIndex);
|
|
566
|
+
// doc-change event fires from reorderSongsInDoc -> flushDoc, driving refresh
|
|
567
|
+
|
|
568
|
+
// refresh audio queue if this playlist is currently playing
|
|
569
|
+
const currentPlaylist = audioState.currentPlaylist();
|
|
570
|
+
if (currentPlaylist && currentPlaylist.id === playlist.id) {
|
|
571
|
+
const updated = selectedPlaylist();
|
|
572
|
+
if (updated) {
|
|
573
|
+
await refreshPlaylistQueue(updated);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} catch (err) {
|
|
577
|
+
log.error("playlist.songs", "error reordering songz:", err);
|
|
578
|
+
setError("failed to reorder songz");
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const handleCachePlaylist = async () => {
|
|
583
|
+
const songs = playlistSongs();
|
|
584
|
+
if (songs.length === 0) return;
|
|
585
|
+
|
|
586
|
+
setIsCaching(true);
|
|
587
|
+
try {
|
|
588
|
+
setError(null);
|
|
589
|
+
|
|
590
|
+
for (const song of songs) {
|
|
591
|
+
// songs are now blob-store backed; use blobUrl if available
|
|
592
|
+
const url = song.blobUrl;
|
|
593
|
+
if (url && song.id) {
|
|
594
|
+
try {
|
|
595
|
+
await cacheAudioFile(url, song.title || "unknown song");
|
|
596
|
+
} catch {
|
|
597
|
+
// ignore individual caching failures
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
setAllSongsCached(true);
|
|
603
|
+
} catch (err) {
|
|
604
|
+
log.error("playlist.cache", "error caching playlist:", err);
|
|
605
|
+
setError("failed to cache playlist for offline use!");
|
|
606
|
+
} finally {
|
|
607
|
+
setIsCaching(false);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
onMount(initialize);
|
|
612
|
+
|
|
613
|
+
onCleanup(() => {
|
|
614
|
+
if (docStoreCleanup) {
|
|
615
|
+
docStoreCleanup();
|
|
616
|
+
docStoreCleanup = null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const cache = imageUrlCache();
|
|
620
|
+
cache.forEach((url) => {
|
|
621
|
+
if (url.startsWith("blob:")) {
|
|
622
|
+
URL.revokeObjectURL(url);
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
cache.clear();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// auto clear error after some time
|
|
629
|
+
createEffect(() => {
|
|
630
|
+
const errorMsg = error();
|
|
631
|
+
if (errorMsg) {
|
|
632
|
+
const timeoutId = setTimeout(() => {
|
|
633
|
+
setError(null);
|
|
634
|
+
}, 10_000);
|
|
635
|
+
|
|
636
|
+
onCleanup(() => clearTimeout(timeoutId));
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
playlists,
|
|
642
|
+
selectedPlaylist,
|
|
643
|
+
playlistSongs,
|
|
644
|
+
isInitialized,
|
|
645
|
+
error,
|
|
646
|
+
backgroundImageUrl,
|
|
647
|
+
backgroundSource,
|
|
648
|
+
imageUrlCache,
|
|
649
|
+
|
|
650
|
+
// modal and UI state
|
|
651
|
+
showImageModal,
|
|
652
|
+
showDeleteConfirm,
|
|
653
|
+
modalImageIndex,
|
|
654
|
+
isDownloading,
|
|
655
|
+
isCaching,
|
|
656
|
+
allSongsCached,
|
|
657
|
+
|
|
658
|
+
// setterz
|
|
659
|
+
setSelectedPlaylist,
|
|
660
|
+
setPlaylistSongs,
|
|
661
|
+
setShowImageModal,
|
|
662
|
+
setShowDeleteConfirm,
|
|
663
|
+
setModalImageIndex,
|
|
664
|
+
setBackgroundOverride,
|
|
665
|
+
|
|
666
|
+
// actionz
|
|
667
|
+
initialize,
|
|
668
|
+
createNewPlaylist,
|
|
669
|
+
handleFileDrop,
|
|
670
|
+
selectPlaylist,
|
|
671
|
+
selectById,
|
|
672
|
+
handlePlaylistUpdate,
|
|
673
|
+
handleDeletePlaylist,
|
|
674
|
+
handleDownloadPlaylist,
|
|
675
|
+
handleRemoveSong,
|
|
676
|
+
handleReorderSongs,
|
|
677
|
+
handleCachePlaylist,
|
|
678
|
+
|
|
679
|
+
// utilz
|
|
680
|
+
getPlaylistById,
|
|
681
|
+
playlistExists,
|
|
682
|
+
getPlaylistCount,
|
|
683
|
+
searchPlaylists,
|
|
684
|
+
};
|
|
685
|
+
}
|