@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,1395 @@
|
|
|
1
|
+
// audio service with a functional approach
|
|
2
|
+
// uses solidjs-style signals for reactive state management
|
|
3
|
+
|
|
4
|
+
import { createSignal } from "solid-js";
|
|
5
|
+
import type { Song, Playlist, AudioState } from "../types/playlist.js";
|
|
6
|
+
import { loadAllPlaybackPositions, savePlaybackPosition, deletePlaybackPosition, saveLastPlayed, loadLastPlayed } from "./indexedDBService.js";
|
|
7
|
+
import { getBlobObjectURL } from "@freqhole/api-client/storage";
|
|
8
|
+
import { fetchSongBlob, prefetchUpcoming } from "./blobTransferService.js";
|
|
9
|
+
import { getSongsForPlaylist } from "./playlistDocService.js";
|
|
10
|
+
import { enrichSongsWithStandalonePaths } from "./standaloneService.js";
|
|
11
|
+
import {
|
|
12
|
+
streamAudioWithCaching,
|
|
13
|
+
downloadSongIfNeeded,
|
|
14
|
+
isSongDownloading,
|
|
15
|
+
} from "./streamingAudioService.js";
|
|
16
|
+
|
|
17
|
+
// audio state signals
|
|
18
|
+
const [currentSong, setCurrentSong] = createSignal<Song | null>(null);
|
|
19
|
+
const [currentPlaylist, setCurrentPlaylist] = createSignal<Playlist | null>(
|
|
20
|
+
null
|
|
21
|
+
);
|
|
22
|
+
const [playlistQueue, setPlaylistQueue] = createSignal<Song[]>([]);
|
|
23
|
+
const [isPlaying, setIsPlaying] = createSignal(false);
|
|
24
|
+
const [currentTime, setCurrentTime] = createSignal(0);
|
|
25
|
+
const [duration, setDuration] = createSignal(0);
|
|
26
|
+
const [currentIndex, setCurrentIndex] = createSignal(-1);
|
|
27
|
+
const [volume, setVolume] = createSignal(1.0);
|
|
28
|
+
const [isLoading, setIsLoading] = createSignal(false);
|
|
29
|
+
const [loadingSongIds, setLoadingSongIds] = createSignal<Set<string>>(
|
|
30
|
+
new Set()
|
|
31
|
+
);
|
|
32
|
+
const [selectedSongId, setSelectedSongId] = createSignal<string | null>(null);
|
|
33
|
+
const [preloadingSongId, setPreloadingSongId] = createSignal<string | null>(
|
|
34
|
+
null
|
|
35
|
+
);
|
|
36
|
+
let hasTriggeredPreload = false;
|
|
37
|
+
const [repeatMode, setRepeatMode] = createSignal<"none" | "one" | "all">(
|
|
38
|
+
"none"
|
|
39
|
+
);
|
|
40
|
+
const [isShuffled, setIsShuffled] = createSignal(false);
|
|
41
|
+
|
|
42
|
+
// download progress tracking
|
|
43
|
+
const [downloadProgress, setDownloadProgress] = createSignal<
|
|
44
|
+
Map<string, number>
|
|
45
|
+
>(new Map());
|
|
46
|
+
const [cachingSongIds, setCachingSongIds] = createSignal<Set<string>>(
|
|
47
|
+
new Set()
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// per-song saved playback positions (songId -> seconds), persists across pause/track switch
|
|
51
|
+
const [songPlaybackPositions, setSongPlaybackPositions] = createSignal<Map<string, number>>(new Map());
|
|
52
|
+
|
|
53
|
+
// pending seek time to apply after loadedmetadata fires for the new song
|
|
54
|
+
let pendingSeekTime = 0;
|
|
55
|
+
|
|
56
|
+
// whether the current audio.src was created from an in-memory File object
|
|
57
|
+
// (vs a blob store url). only file-based urls should be explicitly revoked;
|
|
58
|
+
// blob store urls are backed by persistent opfs data and may be cached by
|
|
59
|
+
// getBlobObjectURL - revoking them causes the cached handle to become stale,
|
|
60
|
+
// resulting in WebKitBlobResource errors on replay.
|
|
61
|
+
let currentAudioNeedsRevoke = false;
|
|
62
|
+
|
|
63
|
+
// load all persisted positions from indexeddb into the in-memory signal
|
|
64
|
+
let positionsLoaded = false;
|
|
65
|
+
async function ensurePositionsLoaded(): Promise<void> {
|
|
66
|
+
if (positionsLoaded) return;
|
|
67
|
+
positionsLoaded = true;
|
|
68
|
+
const saved = await loadAllPlaybackPositions();
|
|
69
|
+
if (saved.size > 0) {
|
|
70
|
+
setSongPlaybackPositions(saved);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// load positions eagerly so song rows can show progress fills before playback starts
|
|
75
|
+
ensurePositionsLoaded().catch(() => {});
|
|
76
|
+
|
|
77
|
+
// persist current song position to indexeddb (fire-and-forget)
|
|
78
|
+
function flushCurrentPosition(): void {
|
|
79
|
+
const song = currentSong();
|
|
80
|
+
const pos = audioElement?.currentTime ?? 0;
|
|
81
|
+
if (song && pos > 1) {
|
|
82
|
+
savePlaybackPosition(song.id, pos).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// single audio element for the entire app
|
|
87
|
+
let audioElement: HTMLAudioElement | null = null;
|
|
88
|
+
|
|
89
|
+
// Initialize audio element
|
|
90
|
+
function initializeAudio(): HTMLAudioElement {
|
|
91
|
+
if (audioElement) return audioElement;
|
|
92
|
+
|
|
93
|
+
audioElement = new Audio();
|
|
94
|
+
audioElement.volume = volume();
|
|
95
|
+
audioElement.preload = "metadata";
|
|
96
|
+
|
|
97
|
+
// load persisted positions from indexeddb on first use
|
|
98
|
+
ensurePositionsLoaded().catch(() => {});
|
|
99
|
+
|
|
100
|
+
// save current position when page is closed / refreshed
|
|
101
|
+
window.addEventListener("beforeunload", flushCurrentPosition);
|
|
102
|
+
|
|
103
|
+
// audio event listenerz
|
|
104
|
+
audioElement.addEventListener("loadstart", () => {
|
|
105
|
+
setIsLoading(true);
|
|
106
|
+
});
|
|
107
|
+
audioElement.addEventListener("canplay", () => {
|
|
108
|
+
setIsLoading(false);
|
|
109
|
+
// note: don't clear loadingSongIds here as it's handled in playSong
|
|
110
|
+
});
|
|
111
|
+
audioElement.addEventListener("loadedmetadata", () => {
|
|
112
|
+
const newDuration = audioElement?.duration || 0;
|
|
113
|
+
setDuration(newDuration);
|
|
114
|
+
setIsLoading(false);
|
|
115
|
+
// apply resume position if set
|
|
116
|
+
if (pendingSeekTime > 0 && audioElement) {
|
|
117
|
+
audioElement.currentTime = pendingSeekTime;
|
|
118
|
+
setCurrentTime(pendingSeekTime);
|
|
119
|
+
pendingSeekTime = 0;
|
|
120
|
+
}
|
|
121
|
+
// update media session now that we have proper metadata
|
|
122
|
+
updateMediaSession();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
audioElement.addEventListener("timeupdate", () => {
|
|
126
|
+
const newCurrentTime = audioElement?.currentTime || 0;
|
|
127
|
+
setCurrentTime(newCurrentTime);
|
|
128
|
+
|
|
129
|
+
// save position for current song so it can be resumed later
|
|
130
|
+
const song = currentSong();
|
|
131
|
+
if (song && newCurrentTime > 0) {
|
|
132
|
+
setSongPlaybackPositions((prev) => {
|
|
133
|
+
const next = new Map(prev);
|
|
134
|
+
next.set(song.id, newCurrentTime);
|
|
135
|
+
return next;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// try to preload next song at halfway point
|
|
140
|
+
const duration = audioElement?.duration || 0;
|
|
141
|
+
if (
|
|
142
|
+
duration > 0 &&
|
|
143
|
+
newCurrentTime / duration >= 0.5 &&
|
|
144
|
+
!hasTriggeredPreload
|
|
145
|
+
) {
|
|
146
|
+
hasTriggeredPreload = true;
|
|
147
|
+
preloadNextSong();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
audioElement.addEventListener("play", () => {
|
|
152
|
+
setIsPlaying(true);
|
|
153
|
+
hasTriggeredPreload = false; // reset preload flag for new song
|
|
154
|
+
// only update media session if not in a loading state
|
|
155
|
+
if (!isLoading()) {
|
|
156
|
+
updateMediaSession();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
audioElement.addEventListener("pause", () => {
|
|
160
|
+
setIsPlaying(false);
|
|
161
|
+
// persist position when pausing so it survives page reload
|
|
162
|
+
flushCurrentPosition();
|
|
163
|
+
updateMediaSession();
|
|
164
|
+
});
|
|
165
|
+
audioElement.addEventListener("ended", () => {
|
|
166
|
+
setIsPlaying(false);
|
|
167
|
+
// mark a complete listen by saving the full duration - the row keeps a
|
|
168
|
+
// full progress fill, and next play restarts from the beginning since
|
|
169
|
+
// positions >=95% are treated as complete
|
|
170
|
+
const song = currentSong();
|
|
171
|
+
const dur = audioElement?.duration ?? 0;
|
|
172
|
+
if (song && dur > 0) {
|
|
173
|
+
setSongPlaybackPositions((prev) => {
|
|
174
|
+
const next = new Map(prev);
|
|
175
|
+
next.set(song.id, dur);
|
|
176
|
+
return next;
|
|
177
|
+
});
|
|
178
|
+
savePlaybackPosition(song.id, dur).catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
handleSongEnded();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
audioElement.addEventListener("error", (e) => {
|
|
184
|
+
console.error("onoz! audio error:", e);
|
|
185
|
+
setIsPlaying(false);
|
|
186
|
+
setIsLoading(false);
|
|
187
|
+
// clear all loading songz on audio error
|
|
188
|
+
setLoadingSongIds(new Set<string>());
|
|
189
|
+
updatePageTitle();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
audioElement.addEventListener("seeked", () => {
|
|
193
|
+
const playingPl = currentPlaylist();
|
|
194
|
+
const song = currentSong();
|
|
195
|
+
if (!playingPl || !song || !isPlaying()) return;
|
|
196
|
+
const elapsed = audioElement?.currentTime ?? 0;
|
|
197
|
+
const dur = audioElement?.duration ?? song.duration ?? 0;
|
|
198
|
+
const remaining = Math.max(0, dur - elapsed);
|
|
199
|
+
prefetchUpcoming(playingPl, song.id, remaining);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return audioElement;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createAudioURL(file: File): string {
|
|
206
|
+
return URL.createObjectURL(file);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// trash old blob URL
|
|
210
|
+
function releaseAudioURL(url: string): void {
|
|
211
|
+
URL.revokeObjectURL(url);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// update page title with currently playing song
|
|
215
|
+
function updatePageTitle(): void {
|
|
216
|
+
const song = currentSong();
|
|
217
|
+
|
|
218
|
+
if (song) {
|
|
219
|
+
document.title = `${song.title} - ${song.artist || "unknown artist"} | P L A Y L I S T Z`;
|
|
220
|
+
} else {
|
|
221
|
+
document.title = "P L A Y L I S T Z";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Media Session API stuff
|
|
226
|
+
async function updateMediaSession(): Promise<void> {
|
|
227
|
+
if (!("mediaSession" in navigator)) return;
|
|
228
|
+
|
|
229
|
+
const song = currentSong();
|
|
230
|
+
const playlist = currentPlaylist();
|
|
231
|
+
const loading = isLoading();
|
|
232
|
+
|
|
233
|
+
if (song) {
|
|
234
|
+
const artwork = await getMediaSessionArtwork(song, playlist || undefined);
|
|
235
|
+
|
|
236
|
+
// clear metadata first, then set it; cuz iOS Safari
|
|
237
|
+
navigator.mediaSession.metadata = null;
|
|
238
|
+
|
|
239
|
+
navigator.mediaSession.metadata = new MediaMetadata({
|
|
240
|
+
title: loading ? `loading... ${song.title}` : song.title,
|
|
241
|
+
artist: song.artist || "unknown artist",
|
|
242
|
+
album: song.album || playlist?.title || "unknown album",
|
|
243
|
+
artwork,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// set playback state - show paused during loading to prevent timing issuez
|
|
247
|
+
// #TODO: is there a loading state to set here?!
|
|
248
|
+
navigator.mediaSession.playbackState = loading
|
|
249
|
+
? "paused"
|
|
250
|
+
: isPlaying()
|
|
251
|
+
? "playing"
|
|
252
|
+
: "paused";
|
|
253
|
+
|
|
254
|
+
// setup player control action handlerz
|
|
255
|
+
navigator.mediaSession.setActionHandler("play", () => {
|
|
256
|
+
togglePlayback();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
navigator.mediaSession.setActionHandler("pause", () => {
|
|
260
|
+
togglePlayback();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
navigator.mediaSession.setActionHandler("previoustrack", () => {
|
|
264
|
+
playPrevious();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
navigator.mediaSession.setActionHandler("nexttrack", () => {
|
|
268
|
+
playNext();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
navigator.mediaSession.setActionHandler("seekto", (details) => {
|
|
272
|
+
if (details.seekTime !== undefined && !loading) {
|
|
273
|
+
seek(details.seekTime);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// set position state only if we have valid duration and are not loading
|
|
278
|
+
const duration = audioState.duration();
|
|
279
|
+
const currentTime = audioState.currentTime();
|
|
280
|
+
if (duration > 0 && !loading) {
|
|
281
|
+
navigator.mediaSession.setPositionState({
|
|
282
|
+
duration,
|
|
283
|
+
playbackRate: 1,
|
|
284
|
+
position: currentTime,
|
|
285
|
+
});
|
|
286
|
+
} else if (loading) {
|
|
287
|
+
// clear position state during loading
|
|
288
|
+
try {
|
|
289
|
+
navigator.mediaSession.setPositionState({
|
|
290
|
+
duration: 0,
|
|
291
|
+
playbackRate: 1,
|
|
292
|
+
position: 0,
|
|
293
|
+
});
|
|
294
|
+
} catch {
|
|
295
|
+
// some browsers don't support clearing position state, ignore error
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
navigator.mediaSession.metadata = null;
|
|
300
|
+
navigator.mediaSession.playbackState = "none";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
updatePageTitle();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// resize image if it's too large for ios safari mediasession
|
|
307
|
+
async function resizeImageForMediaSession(
|
|
308
|
+
imageData: ArrayBuffer,
|
|
309
|
+
mimeType: string
|
|
310
|
+
): Promise<ArrayBuffer> {
|
|
311
|
+
// if image is smaller than 500kb, use as-is
|
|
312
|
+
if (imageData.byteLength < 500000) {
|
|
313
|
+
return imageData;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return new Promise((resolve) => {
|
|
317
|
+
const blob = new Blob([imageData], { type: mimeType });
|
|
318
|
+
const img = new Image();
|
|
319
|
+
const canvas = document.createElement("canvas");
|
|
320
|
+
const ctx = canvas.getContext("2d");
|
|
321
|
+
|
|
322
|
+
img.onload = () => {
|
|
323
|
+
// resize to max 300x300 to keep file size reasonable
|
|
324
|
+
const maxSize = 300;
|
|
325
|
+
let { width, height } = img;
|
|
326
|
+
|
|
327
|
+
if (width > maxSize || height > maxSize) {
|
|
328
|
+
const ratio = Math.min(maxSize / width, maxSize / height);
|
|
329
|
+
width *= ratio;
|
|
330
|
+
height *= ratio;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
canvas.width = width;
|
|
334
|
+
canvas.height = height;
|
|
335
|
+
|
|
336
|
+
if (ctx) {
|
|
337
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
338
|
+
|
|
339
|
+
canvas.toBlob(
|
|
340
|
+
(resizedBlob) => {
|
|
341
|
+
if (resizedBlob) {
|
|
342
|
+
resizedBlob.arrayBuffer().then(resolve);
|
|
343
|
+
} else {
|
|
344
|
+
resolve(imageData); // fallback to original
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
mimeType,
|
|
348
|
+
0.8
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
resolve(imageData); // fallback to original
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
img.onerror = () => {
|
|
356
|
+
resolve(imageData); // fallback to original
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
img.src = URL.createObjectURL(blob);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// get artwork for Media Session
|
|
364
|
+
async function getMediaSessionArtwork(
|
|
365
|
+
song: Song,
|
|
366
|
+
playlist?: Playlist
|
|
367
|
+
): Promise<MediaImage[]> {
|
|
368
|
+
const artwork: MediaImage[] = [];
|
|
369
|
+
|
|
370
|
+
// try song image first (prefer thumbnail for MediaSession)
|
|
371
|
+
const songImageData = song.thumbnailData || song.imageData;
|
|
372
|
+
if (songImageData && song.imageType) {
|
|
373
|
+
const resizedImageData = await resizeImageForMediaSession(
|
|
374
|
+
songImageData,
|
|
375
|
+
song.imageType
|
|
376
|
+
);
|
|
377
|
+
const blob = new Blob([resizedImageData], { type: song.imageType });
|
|
378
|
+
const url = URL.createObjectURL(blob);
|
|
379
|
+
// add multiple sizes for ios safari compatibility
|
|
380
|
+
artwork.push({
|
|
381
|
+
src: url,
|
|
382
|
+
sizes: "512x512",
|
|
383
|
+
type: song.imageType,
|
|
384
|
+
});
|
|
385
|
+
artwork.push({
|
|
386
|
+
src: url,
|
|
387
|
+
sizes: "256x256",
|
|
388
|
+
type: song.imageType,
|
|
389
|
+
});
|
|
390
|
+
artwork.push({
|
|
391
|
+
src: url,
|
|
392
|
+
sizes: "96x96",
|
|
393
|
+
type: song.imageType,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// fallback to playlist image (prefer thumbnail for mediasession)
|
|
397
|
+
else {
|
|
398
|
+
const playlistImageData = playlist?.thumbnailData || playlist?.imageData;
|
|
399
|
+
if (playlistImageData && playlist?.imageType) {
|
|
400
|
+
const resizedImageData = await resizeImageForMediaSession(
|
|
401
|
+
playlistImageData,
|
|
402
|
+
playlist.imageType
|
|
403
|
+
);
|
|
404
|
+
const blob = new Blob([resizedImageData], { type: playlist.imageType });
|
|
405
|
+
const url = URL.createObjectURL(blob);
|
|
406
|
+
// add multiple sizes for ios safari compatibility
|
|
407
|
+
artwork.push({
|
|
408
|
+
src: url,
|
|
409
|
+
sizes: "512x512",
|
|
410
|
+
type: playlist.imageType,
|
|
411
|
+
});
|
|
412
|
+
artwork.push({
|
|
413
|
+
src: url,
|
|
414
|
+
sizes: "256x256",
|
|
415
|
+
type: playlist.imageType,
|
|
416
|
+
});
|
|
417
|
+
artwork.push({
|
|
418
|
+
src: url,
|
|
419
|
+
sizes: "96x96",
|
|
420
|
+
type: playlist.imageType,
|
|
421
|
+
});
|
|
422
|
+
} else {
|
|
423
|
+
// oops, no artwork available!
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return artwork;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// load playlist songz into queue
|
|
431
|
+
async function loadPlaylistQueue(playlist: Playlist): Promise<void> {
|
|
432
|
+
try {
|
|
433
|
+
// songs come from the automerge doc; playlist.id is the docId
|
|
434
|
+
const playlistSongs = await getSongsForPlaylist(playlist.id);
|
|
435
|
+
setPlaylistQueue(enrichSongsWithStandalonePaths(playlistSongs));
|
|
436
|
+
setCurrentPlaylist(playlist);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
console.error("error loading playlist queue:", error);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// refresh playlist queue while maintaining current song position
|
|
444
|
+
export async function refreshPlaylistQueue(playlist: Playlist): Promise<void> {
|
|
445
|
+
try {
|
|
446
|
+
const currentSong = audioState.currentSong();
|
|
447
|
+
await loadPlaylistQueue(playlist);
|
|
448
|
+
|
|
449
|
+
// update current index to match new position of currently playing song
|
|
450
|
+
if (currentSong) {
|
|
451
|
+
const queue = playlistQueue();
|
|
452
|
+
const newIndex = queue.findIndex((song) => song.id === currentSong.id);
|
|
453
|
+
setCurrentIndex(newIndex >= 0 ? newIndex : -1);
|
|
454
|
+
}
|
|
455
|
+
} catch (error) {
|
|
456
|
+
console.error("Error refreshing playlist queue:", error);
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// get the next song in queue
|
|
462
|
+
function getNextSong(): Song | null {
|
|
463
|
+
const queue = playlistQueue();
|
|
464
|
+
const currentIdx = currentIndex();
|
|
465
|
+
|
|
466
|
+
if (queue.length === 0) return null;
|
|
467
|
+
|
|
468
|
+
// note: repeat mode is mostly unused
|
|
469
|
+
const repeat = repeatMode();
|
|
470
|
+
|
|
471
|
+
if (repeat === "one") {
|
|
472
|
+
// repeat current song
|
|
473
|
+
return currentIdx >= 0 ? queue[currentIdx] || null : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const nextIdx = currentIdx + 1;
|
|
477
|
+
|
|
478
|
+
if (nextIdx < queue.length) {
|
|
479
|
+
return queue[nextIdx] || null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (repeat === "all") {
|
|
483
|
+
// loop back to first song
|
|
484
|
+
return queue[0] || null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// no repeat, end of queue
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// bet the previous song in queue
|
|
492
|
+
function getPreviousSong(): Song | null {
|
|
493
|
+
const queue = playlistQueue();
|
|
494
|
+
const currentIdx = currentIndex();
|
|
495
|
+
|
|
496
|
+
if (queue.length === 0 || currentIdx <= 0) return null;
|
|
497
|
+
|
|
498
|
+
return queue[currentIdx - 1] || null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// skip to next playable song when current next song fails
|
|
502
|
+
async function skipToNextPlayableSong(): Promise<void> {
|
|
503
|
+
const queue = playlistQueue();
|
|
504
|
+
const currentIdx = currentIndex();
|
|
505
|
+
const repeat = repeatMode();
|
|
506
|
+
|
|
507
|
+
// try each subsequent song until we find one that plays or reach the end
|
|
508
|
+
let testIndex = currentIdx + 1;
|
|
509
|
+
const maxAttempts = queue.length; // prevent infinite loops
|
|
510
|
+
let attempts = 0;
|
|
511
|
+
|
|
512
|
+
while (attempts < maxAttempts) {
|
|
513
|
+
// handle wrap-around for repeat all mode
|
|
514
|
+
if (testIndex >= queue.length) {
|
|
515
|
+
if (repeat === "all") {
|
|
516
|
+
testIndex = 0;
|
|
517
|
+
} else {
|
|
518
|
+
// end of queue, no repeat
|
|
519
|
+
setIsPlaying(false);
|
|
520
|
+
updateMediaSession();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// don't retry the same song we just failed on
|
|
526
|
+
if (testIndex === currentIdx) {
|
|
527
|
+
setIsPlaying(false);
|
|
528
|
+
updateMediaSession();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const testSong = queue[testIndex];
|
|
533
|
+
if (!testSong) {
|
|
534
|
+
testIndex++;
|
|
535
|
+
attempts++;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
setCurrentIndex(testIndex);
|
|
541
|
+
setSelectedSongId(testSong.id);
|
|
542
|
+
await playSong(testSong);
|
|
543
|
+
return; // success!
|
|
544
|
+
} catch (error) {
|
|
545
|
+
console.error(`onoz! failed to play song "${testSong.title}":`, error);
|
|
546
|
+
testIndex++;
|
|
547
|
+
attempts++;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// if we get here, all songs failed to load
|
|
552
|
+
console.error("oopz! all remaining songs failed to load, stopping playback");
|
|
553
|
+
setIsPlaying(false);
|
|
554
|
+
updateMediaSession();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// handle song ended - auto-advance logic
|
|
558
|
+
async function handleSongEnded(): Promise<void> {
|
|
559
|
+
const nextSong = getNextSong();
|
|
560
|
+
if (nextSong) {
|
|
561
|
+
try {
|
|
562
|
+
await playNext();
|
|
563
|
+
} catch (error) {
|
|
564
|
+
console.error("error during auto-advance:", error);
|
|
565
|
+
// if playNext fails, try to skip to the song after that
|
|
566
|
+
await skipToNextPlayableSong();
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
// stay on last song but stop playing
|
|
570
|
+
setIsPlaying(false);
|
|
571
|
+
updateMediaSession();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// play a specific song
|
|
576
|
+
export async function playSong(song: Song, skipResume = false): Promise<void> {
|
|
577
|
+
const audio = initializeAudio();
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
// ensure persisted positions are loaded before checking resume state
|
|
581
|
+
await ensurePositionsLoaded();
|
|
582
|
+
|
|
583
|
+
// save the outgoing song's position before switching tracks
|
|
584
|
+
flushCurrentPosition();
|
|
585
|
+
|
|
586
|
+
// set this as the selected song immediately
|
|
587
|
+
setSelectedSongId(song.id);
|
|
588
|
+
|
|
589
|
+
// add this song to loading set
|
|
590
|
+
setLoadingSongIds((prev) => new Set(Array.from(prev).concat([song.id])));
|
|
591
|
+
|
|
592
|
+
// clear preloading state if this song was being preloaded
|
|
593
|
+
if (preloadingSongId() === song.id) {
|
|
594
|
+
setPreloadingSongId(null);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// only revoke file-backed blob urls (created from File objects). blob
|
|
598
|
+
// store urls (getBlobObjectURL) are backed by persistent opfs data and
|
|
599
|
+
// may be cached internally - revoking them causes the cache to return
|
|
600
|
+
// the same now-invalid url on replay (WebKitBlobResource error 1).
|
|
601
|
+
if (currentAudioNeedsRevoke && audio.src && audio.src.startsWith("blob:")) {
|
|
602
|
+
releaseAudioURL(audio.src);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// reset time/duration immediately to prevent stale values
|
|
606
|
+
setCurrentTime(0);
|
|
607
|
+
setDuration(0);
|
|
608
|
+
audio.currentTime = 0;
|
|
609
|
+
|
|
610
|
+
// check for a saved position to resume from.
|
|
611
|
+
// if the saved position is <95% of the song's known duration, resume from there.
|
|
612
|
+
// otherwise (near end or unknown duration) start from the beginning.
|
|
613
|
+
// skipResume=true when auto-advancing (song ended naturally) - always start fresh.
|
|
614
|
+
const savedPos = skipResume ? 0 : (songPlaybackPositions().get(song.id) ?? 0);
|
|
615
|
+
const knownDuration = song.duration ?? 0;
|
|
616
|
+
if (savedPos > 0 && (knownDuration === 0 || savedPos < knownDuration * 0.95)) {
|
|
617
|
+
pendingSeekTime = savedPos;
|
|
618
|
+
} else {
|
|
619
|
+
pendingSeekTime = 0;
|
|
620
|
+
// if near the end, clear the stale saved position so it restarts cleanly
|
|
621
|
+
if (savedPos > 0) {
|
|
622
|
+
setSongPlaybackPositions((prev) => {
|
|
623
|
+
const next = new Map(prev);
|
|
624
|
+
next.delete(song.id);
|
|
625
|
+
return next;
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
setIsLoading(true);
|
|
631
|
+
setCurrentSong(song);
|
|
632
|
+
|
|
633
|
+
// update media session immediately with new song info (fixes iOS lock screen image issue)
|
|
634
|
+
await updateMediaSession();
|
|
635
|
+
|
|
636
|
+
// clear media session position state during loading to prevent timing issues
|
|
637
|
+
if ("mediaSession" in navigator) {
|
|
638
|
+
navigator.mediaSession.playbackState = "paused";
|
|
639
|
+
// clear position state to stop time updates from old song
|
|
640
|
+
try {
|
|
641
|
+
navigator.mediaSession.setPositionState({
|
|
642
|
+
duration: 0,
|
|
643
|
+
playbackRate: 1,
|
|
644
|
+
position: 0,
|
|
645
|
+
});
|
|
646
|
+
} catch {
|
|
647
|
+
// some browsers don't support clearing position state, ignore error
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// check playlist context and load queue if needed
|
|
652
|
+
const queue = playlistQueue();
|
|
653
|
+
|
|
654
|
+
// try to find song in current queue first
|
|
655
|
+
const queueIndex = queue.findIndex((queueSong) => queueSong.id === song.id);
|
|
656
|
+
if (queueIndex >= 0) {
|
|
657
|
+
// song is in current queue, use it
|
|
658
|
+
setCurrentIndex(queueIndex);
|
|
659
|
+
// save last played for this playlist so we can resume on reload
|
|
660
|
+
const pl = currentPlaylist();
|
|
661
|
+
if (pl) {
|
|
662
|
+
saveLastPlayed(pl.id, song.id);
|
|
663
|
+
}
|
|
664
|
+
} else {
|
|
665
|
+
// song not in current queue, clear playlist context for single song play
|
|
666
|
+
setCurrentPlaylist(null);
|
|
667
|
+
setPlaylistQueue([]);
|
|
668
|
+
setCurrentIndex(-1);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// try to get audio url in order of preference:
|
|
672
|
+
// 1. existing bloburl from song
|
|
673
|
+
// 2. create from file if available
|
|
674
|
+
// 3. load from indexeddb on-demand
|
|
675
|
+
let audioURL = song.blobUrl;
|
|
676
|
+
// track whether the resolved url was created from an in-memory File
|
|
677
|
+
// (needs explicit revocation) vs from the blob store (should not be revoked)
|
|
678
|
+
let audioUrlIsFileBacked = false;
|
|
679
|
+
if (!audioURL && song.file) {
|
|
680
|
+
audioURL = createAudioURL(song.file);
|
|
681
|
+
audioUrlIsFileBacked = true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (!audioURL) {
|
|
685
|
+
// check for standalone file path when using file:// protocol
|
|
686
|
+
if (window.location.protocol === "file:" && song.standaloneFilePath) {
|
|
687
|
+
const filePath = song.standaloneFilePath;
|
|
688
|
+
audioURL = new URL(filePath, window.location.href).href;
|
|
689
|
+
|
|
690
|
+
// test if the file is accessible
|
|
691
|
+
const testAudio = document.createElement("audio");
|
|
692
|
+
testAudio.src = audioURL;
|
|
693
|
+
testAudio.addEventListener("error", (e) => {
|
|
694
|
+
console.error("audio file test failed:", e);
|
|
695
|
+
console.error("audio error:", testAudio.error);
|
|
696
|
+
});
|
|
697
|
+
testAudio.load();
|
|
698
|
+
} else {
|
|
699
|
+
// try to load audio from the blob store (sha256-keyed opfs)
|
|
700
|
+
let cachedURL: string | null = null;
|
|
701
|
+
const sha = song.sha ?? song.sha256;
|
|
702
|
+
if (sha) {
|
|
703
|
+
cachedURL = await getBlobObjectURL(sha);
|
|
704
|
+
}
|
|
705
|
+
if (!cachedURL && sha && song.playlistId && !song.standaloneFilePath) {
|
|
706
|
+
// blob not local - try fetching from the playlist's p2p peers
|
|
707
|
+
setCachingSongIds(
|
|
708
|
+
(prev) => new Set(Array.from(prev).concat([song.id]))
|
|
709
|
+
);
|
|
710
|
+
try {
|
|
711
|
+
const fetched = await fetchSongBlob(song, (p) => {
|
|
712
|
+
setDownloadProgress((prev) => {
|
|
713
|
+
const newMap = new Map(prev);
|
|
714
|
+
newMap.set(song.id, Math.round(p.fraction * 100));
|
|
715
|
+
return newMap;
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
if (fetched) {
|
|
719
|
+
cachedURL = await getBlobObjectURL(sha);
|
|
720
|
+
}
|
|
721
|
+
} catch (err) {
|
|
722
|
+
console.warn("p2p audio fetch failed:", err);
|
|
723
|
+
} finally {
|
|
724
|
+
setDownloadProgress((prev) => {
|
|
725
|
+
const newMap = new Map(prev);
|
|
726
|
+
newMap.delete(song.id);
|
|
727
|
+
return newMap;
|
|
728
|
+
});
|
|
729
|
+
setCachingSongIds((prev) => {
|
|
730
|
+
const newSet = new Set(prev);
|
|
731
|
+
newSet.delete(song.id);
|
|
732
|
+
return newSet;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (cachedURL) {
|
|
737
|
+
audioURL = cachedURL;
|
|
738
|
+
} else if (song.standaloneFilePath) {
|
|
739
|
+
const filePath = song.standaloneFilePath;
|
|
740
|
+
|
|
741
|
+
// for file:// protocol, use direct path (no caching needed - it's local)
|
|
742
|
+
if (window.location.protocol === "file:") {
|
|
743
|
+
audioURL = new URL(filePath, window.location.href).href;
|
|
744
|
+
} else {
|
|
745
|
+
// for http/https, use streaming approach for immediate playback
|
|
746
|
+
try {
|
|
747
|
+
const { blobUrl, downloadPromise } = await streamAudioWithCaching(
|
|
748
|
+
song,
|
|
749
|
+
filePath,
|
|
750
|
+
(progress) => {
|
|
751
|
+
// update progress tracking
|
|
752
|
+
setDownloadProgress((prev) => {
|
|
753
|
+
const newMap = new Map(prev);
|
|
754
|
+
newMap.set(song.id, progress.percentage);
|
|
755
|
+
return newMap;
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
// track that this song is being cached
|
|
761
|
+
setCachingSongIds(
|
|
762
|
+
(prev) => new Set(Array.from(prev).concat([song.id]))
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
audioURL = blobUrl;
|
|
766
|
+
audioUrlIsFileBacked = true; // blobUrl from streamAudioWithCaching is a temp object url
|
|
767
|
+
|
|
768
|
+
// handle caching completion in background
|
|
769
|
+
downloadPromise
|
|
770
|
+
.then((success) => {
|
|
771
|
+
if (success) {
|
|
772
|
+
console.debug(`successfully cached ${song.title}`);
|
|
773
|
+
} else {
|
|
774
|
+
console.warn(`failed to cache ${song.title}`);
|
|
775
|
+
}
|
|
776
|
+
})
|
|
777
|
+
.catch((error) => {
|
|
778
|
+
console.error(`error caching ${song.title}:`, error);
|
|
779
|
+
})
|
|
780
|
+
.finally(() => {
|
|
781
|
+
// clean up progress tracking
|
|
782
|
+
setDownloadProgress((prev) => {
|
|
783
|
+
const newMap = new Map(prev);
|
|
784
|
+
newMap.delete(song.id);
|
|
785
|
+
return newMap;
|
|
786
|
+
});
|
|
787
|
+
setCachingSongIds((prev) => {
|
|
788
|
+
const newSet = new Set(prev);
|
|
789
|
+
newSet.delete(song.id);
|
|
790
|
+
return newSet;
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
} catch (streamError) {
|
|
794
|
+
console.error(
|
|
795
|
+
"streaming approach failed, using direct url:",
|
|
796
|
+
streamError
|
|
797
|
+
);
|
|
798
|
+
// for http/https, fall back to direct url streaming
|
|
799
|
+
audioURL = filePath;
|
|
800
|
+
|
|
801
|
+
// start background caching separately
|
|
802
|
+
setCachingSongIds(
|
|
803
|
+
(prev) => new Set(Array.from(prev).concat([song.id]))
|
|
804
|
+
);
|
|
805
|
+
downloadSongIfNeeded(song, filePath, (progress) => {
|
|
806
|
+
setDownloadProgress((prev) => {
|
|
807
|
+
const newMap = new Map(prev);
|
|
808
|
+
newMap.set(song.id, progress.percentage);
|
|
809
|
+
return newMap;
|
|
810
|
+
});
|
|
811
|
+
}).finally(() => {
|
|
812
|
+
// clean up progress tracking
|
|
813
|
+
setDownloadProgress((prev) => {
|
|
814
|
+
const newMap = new Map(prev);
|
|
815
|
+
newMap.delete(song.id);
|
|
816
|
+
return newMap;
|
|
817
|
+
});
|
|
818
|
+
setCachingSongIds((prev) => {
|
|
819
|
+
const newSet = new Set(prev);
|
|
820
|
+
newSet.delete(song.id);
|
|
821
|
+
return newSet;
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (!audioURL) {
|
|
831
|
+
throw new Error(
|
|
832
|
+
`no audio source available for song: ${song.title}. check that audio files are accessible.`
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// only continue if this song is still the selected one
|
|
837
|
+
if (selectedSongId() !== song.id) {
|
|
838
|
+
// song is loaded but user has moved on to a different song
|
|
839
|
+
setLoadingSongIds((prev) => {
|
|
840
|
+
const newSet = new Set(prev);
|
|
841
|
+
newSet.delete(song.id);
|
|
842
|
+
return newSet;
|
|
843
|
+
});
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// update the revoke flag before switching src
|
|
848
|
+
currentAudioNeedsRevoke = audioUrlIsFileBacked;
|
|
849
|
+
audio.src = audioURL;
|
|
850
|
+
|
|
851
|
+
// add error event listener to catch loading issues
|
|
852
|
+
audio.addEventListener(
|
|
853
|
+
"error",
|
|
854
|
+
(e) => {
|
|
855
|
+
console.error("audio loading error:", e);
|
|
856
|
+
console.error("audio error details:", audio.error);
|
|
857
|
+
},
|
|
858
|
+
{ once: true }
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
await audio.play();
|
|
862
|
+
|
|
863
|
+
// remove song from loading set since it's now playing
|
|
864
|
+
setLoadingSongIds((prev) => {
|
|
865
|
+
const newSet = new Set(prev);
|
|
866
|
+
newSet.delete(song.id);
|
|
867
|
+
return newSet;
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// prefetch upcoming songs from p2p peers (~30 min rolling window from now)
|
|
871
|
+
const playingPl = currentPlaylist();
|
|
872
|
+
if (playingPl) {
|
|
873
|
+
const elapsed = audioElement?.currentTime ?? 0;
|
|
874
|
+
const dur = audioElement?.duration ?? song.duration ?? 0;
|
|
875
|
+
const remaining = Math.max(0, dur - elapsed);
|
|
876
|
+
prefetchUpcoming(playingPl, song.id, remaining);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// media session will be updated by loadedmetadata event
|
|
880
|
+
} catch (error) {
|
|
881
|
+
console.error("error playing song:", error);
|
|
882
|
+
setIsLoading(false);
|
|
883
|
+
setLoadingSongIds((prev) => {
|
|
884
|
+
const newSet = new Set(prev);
|
|
885
|
+
newSet.delete(song.id);
|
|
886
|
+
return newSet;
|
|
887
|
+
});
|
|
888
|
+
updatePageTitle();
|
|
889
|
+
throw error;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// play a song with playlist context (loads queue if needed)
|
|
894
|
+
export async function playSongFromPlaylist(
|
|
895
|
+
song: Song,
|
|
896
|
+
playlist: Playlist
|
|
897
|
+
): Promise<void> {
|
|
898
|
+
// only reload queue if it's a different playlist or queue is empty
|
|
899
|
+
const currentPl = currentPlaylist();
|
|
900
|
+
if (
|
|
901
|
+
!currentPl ||
|
|
902
|
+
currentPl.id !== playlist.id ||
|
|
903
|
+
playlistQueue().length === 0
|
|
904
|
+
) {
|
|
905
|
+
await loadPlaylistQueue(playlist);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// find song in queue and play it
|
|
909
|
+
const queue = playlistQueue();
|
|
910
|
+
const index = queue.findIndex((queueSong) => queueSong.id === song.id);
|
|
911
|
+
if (index >= 0) {
|
|
912
|
+
setCurrentIndex(index);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
await playSong(song);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// play entire playlist starting from specific index
|
|
919
|
+
export async function playPlaylist(
|
|
920
|
+
playlist: Playlist,
|
|
921
|
+
startIndex = 0
|
|
922
|
+
): Promise<void> {
|
|
923
|
+
await loadPlaylistQueue(playlist);
|
|
924
|
+
|
|
925
|
+
const queue = playlistQueue();
|
|
926
|
+
if (!queue.length) return;
|
|
927
|
+
|
|
928
|
+
// ensure persisted positions are loaded before checking resume state
|
|
929
|
+
await ensurePositionsLoaded();
|
|
930
|
+
|
|
931
|
+
// check if there's a last-played song to resume from
|
|
932
|
+
const lastSongId = await loadLastPlayed(playlist.id);
|
|
933
|
+
if (lastSongId) {
|
|
934
|
+
const lastIdx = queue.findIndex((s) => s.id === lastSongId);
|
|
935
|
+
if (lastIdx >= 0) {
|
|
936
|
+
const lastSong = queue[lastIdx]!;
|
|
937
|
+
const savedPos = songPlaybackPositions().get(lastSongId) ?? 0;
|
|
938
|
+
const knownDuration = lastSong.duration ?? 0;
|
|
939
|
+
const nearEnd = knownDuration > 0 && savedPos >= knownDuration * 0.95;
|
|
940
|
+
|
|
941
|
+
if (!nearEnd) {
|
|
942
|
+
// resume this song at its saved position (pendingSeekTime handles the seek)
|
|
943
|
+
await tryPlaySongFromIndex(lastIdx);
|
|
944
|
+
return;
|
|
945
|
+
} else {
|
|
946
|
+
// song was near end - advance to next, or wrap to start if last song
|
|
947
|
+
const nextIdx = lastIdx + 1 < queue.length ? lastIdx + 1 : 0;
|
|
948
|
+
await tryPlaySongFromIndex(nextIdx);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// no last-played data - start from requested index
|
|
955
|
+
if (startIndex >= queue.length || startIndex < 0) return;
|
|
956
|
+
await tryPlaySongFromIndex(startIndex);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// try to play song at index, falling back to skipToNextPlayableSong if it fails
|
|
960
|
+
async function tryPlaySongFromIndex(startIndex: number): Promise<void> {
|
|
961
|
+
const queue = playlistQueue();
|
|
962
|
+
const song = queue[startIndex];
|
|
963
|
+
if (!song) return;
|
|
964
|
+
|
|
965
|
+
try {
|
|
966
|
+
setCurrentIndex(startIndex);
|
|
967
|
+
setSelectedSongId(song.id);
|
|
968
|
+
await playSong(song);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
console.error(`failed to play song "${song.title}":`, error);
|
|
971
|
+
// use existing skip logic
|
|
972
|
+
await skipToNextPlayableSong();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// play next song in playlist
|
|
977
|
+
export async function playNext(): Promise<void> {
|
|
978
|
+
const queue = playlistQueue();
|
|
979
|
+
const currentIdx = currentIndex();
|
|
980
|
+
|
|
981
|
+
if (queue.length === 0) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const repeat = repeatMode();
|
|
986
|
+
let nextIndex: number;
|
|
987
|
+
|
|
988
|
+
if (repeat === "one") {
|
|
989
|
+
// repeat current song
|
|
990
|
+
nextIndex = currentIdx;
|
|
991
|
+
} else if (currentIdx + 1 < queue.length) {
|
|
992
|
+
// normal next song
|
|
993
|
+
nextIndex = currentIdx + 1;
|
|
994
|
+
} else if (repeat === "all") {
|
|
995
|
+
// loop back to first song
|
|
996
|
+
nextIndex = 0;
|
|
997
|
+
} else {
|
|
998
|
+
// end of queue, no repeat
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const nextSong = queue[nextIndex];
|
|
1003
|
+
if (nextSong) {
|
|
1004
|
+
setCurrentIndex(nextIndex);
|
|
1005
|
+
setSelectedSongId(nextSong.id);
|
|
1006
|
+
try {
|
|
1007
|
+
// auto-advance always starts from beginning, never resumes saved position
|
|
1008
|
+
await playSong(nextSong, true);
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
console.error("error playing next song:", error);
|
|
1011
|
+
throw error; // re-throw for handleSongEnded to catch
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// play previous song in playlist
|
|
1017
|
+
export async function playPrevious(): Promise<void> {
|
|
1018
|
+
const queue = playlistQueue();
|
|
1019
|
+
const currentIdx = currentIndex();
|
|
1020
|
+
|
|
1021
|
+
if (queue.length === 0 || currentIdx <= 0) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const prevIndex = currentIdx - 1;
|
|
1026
|
+
const prevSong = queue[prevIndex];
|
|
1027
|
+
|
|
1028
|
+
if (prevSong) {
|
|
1029
|
+
setCurrentIndex(prevIndex);
|
|
1030
|
+
setSelectedSongId(prevSong.id);
|
|
1031
|
+
await playSong(prevSong);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// toggle play/pause
|
|
1036
|
+
export async function togglePlayback(): Promise<void> {
|
|
1037
|
+
const audio = audioElement;
|
|
1038
|
+
if (!audio) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
try {
|
|
1043
|
+
const currentlyPlaying = isPlaying();
|
|
1044
|
+
|
|
1045
|
+
if (currentlyPlaying) {
|
|
1046
|
+
audio.pause();
|
|
1047
|
+
} else {
|
|
1048
|
+
await audio.play();
|
|
1049
|
+
}
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
console.error("onoz! error toggling playback:", error);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// pause playback
|
|
1056
|
+
export function pause(): void {
|
|
1057
|
+
const audio = audioElement;
|
|
1058
|
+
if (audio && !audio.paused) {
|
|
1059
|
+
audio.pause();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// stop playback and reset
|
|
1064
|
+
export function stop(): void {
|
|
1065
|
+
const audio = audioElement;
|
|
1066
|
+
if (audio) {
|
|
1067
|
+
audio.pause();
|
|
1068
|
+
audio.currentTime = 0;
|
|
1069
|
+
if (audio.src && audio.src.startsWith("blob:")) {
|
|
1070
|
+
releaseAudioURL(audio.src);
|
|
1071
|
+
}
|
|
1072
|
+
audio.src = "";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
setCurrentSong(null);
|
|
1076
|
+
setCurrentPlaylist(null);
|
|
1077
|
+
setIsPlaying(false);
|
|
1078
|
+
setCurrentTime(0);
|
|
1079
|
+
setDuration(0);
|
|
1080
|
+
setCurrentIndex(0);
|
|
1081
|
+
updatePageTitle();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// seek and destroy!
|
|
1085
|
+
export function seek(time: number): void {
|
|
1086
|
+
const audio = audioElement;
|
|
1087
|
+
if (audio && !isNaN(audio.duration)) {
|
|
1088
|
+
const clamped = Math.max(0, Math.min(time, audio.duration));
|
|
1089
|
+
audio.currentTime = clamped;
|
|
1090
|
+
// keep saved position in sync with manual seek
|
|
1091
|
+
const song = currentSong();
|
|
1092
|
+
if (song) {
|
|
1093
|
+
setSongPlaybackPositions((prev) => {
|
|
1094
|
+
const next = new Map(prev);
|
|
1095
|
+
if (clamped < 1) {
|
|
1096
|
+
next.delete(song.id); // seeking to start clears saved position
|
|
1097
|
+
deletePlaybackPosition(song.id).catch(() => {});
|
|
1098
|
+
} else {
|
|
1099
|
+
next.set(song.id, clamped);
|
|
1100
|
+
savePlaybackPosition(song.id, clamped).catch(() => {});
|
|
1101
|
+
}
|
|
1102
|
+
return next;
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// set volume (0 to 1)
|
|
1109
|
+
export function setAudioVolume(newVolume: number): void {
|
|
1110
|
+
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
|
1111
|
+
setVolume(clampedVolume);
|
|
1112
|
+
|
|
1113
|
+
const audio = audioElement;
|
|
1114
|
+
if (audio) {
|
|
1115
|
+
audio.volume = clampedVolume;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// set repeat mode
|
|
1120
|
+
export function setRepeatModeValue(mode: "none" | "one" | "all"): void {
|
|
1121
|
+
setRepeatMode(mode);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// toggle repeat mode
|
|
1125
|
+
export function toggleRepeatMode(): "none" | "one" | "all" {
|
|
1126
|
+
const current = repeatMode();
|
|
1127
|
+
const modes: ("none" | "one" | "all")[] = ["none", "one", "all"];
|
|
1128
|
+
const nextIndex = (modes.indexOf(current) + 1) % modes.length;
|
|
1129
|
+
const nextMode = modes[nextIndex] as "none" | "one" | "all";
|
|
1130
|
+
setRepeatModeValue(nextMode);
|
|
1131
|
+
return nextMode;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// get queue info
|
|
1135
|
+
export function getQueueInfo() {
|
|
1136
|
+
const queue = playlistQueue();
|
|
1137
|
+
const currentIdx = currentIndex();
|
|
1138
|
+
|
|
1139
|
+
return {
|
|
1140
|
+
length: queue.length,
|
|
1141
|
+
currentIndex: currentIdx,
|
|
1142
|
+
hasNext: getNextSong() !== null,
|
|
1143
|
+
hasPrevious: getPreviousSong() !== null,
|
|
1144
|
+
currentSong: currentIdx >= 0 ? queue[currentIdx] : null,
|
|
1145
|
+
nextSong: getNextSong(),
|
|
1146
|
+
previousSong: getPreviousSong(),
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// jump to specific song in queue
|
|
1151
|
+
// #TODO: deal with duplicate fns :/
|
|
1152
|
+
export async function playQueueIndex(index: number): Promise<void> {
|
|
1153
|
+
const queue = playlistQueue();
|
|
1154
|
+
|
|
1155
|
+
if (index < 0 || index >= queue.length) {
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const song = queue[index];
|
|
1160
|
+
if (song) {
|
|
1161
|
+
setCurrentIndex(index);
|
|
1162
|
+
await playSong(song);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// get current audio state
|
|
1167
|
+
export function getAudioState(): AudioState {
|
|
1168
|
+
return {
|
|
1169
|
+
currentSong: currentSong(),
|
|
1170
|
+
currentPlaylist: currentPlaylist(),
|
|
1171
|
+
isPlaying: isPlaying(),
|
|
1172
|
+
currentTime: currentTime(),
|
|
1173
|
+
duration: duration(),
|
|
1174
|
+
volume: volume(),
|
|
1175
|
+
currentIndex: currentIndex(),
|
|
1176
|
+
queue: playlistQueue(),
|
|
1177
|
+
repeatMode: repeatMode(),
|
|
1178
|
+
isShuffled: isShuffled(),
|
|
1179
|
+
isLoading: isLoading(),
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// format time for display
|
|
1184
|
+
export function formatTime(seconds: number): string {
|
|
1185
|
+
if (isNaN(seconds) || seconds < 0 || !isFinite(seconds)) return "0:00";
|
|
1186
|
+
|
|
1187
|
+
const mins = Math.floor(seconds / 60);
|
|
1188
|
+
const secs = Math.floor(seconds % 60);
|
|
1189
|
+
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// export state getters for components to use
|
|
1193
|
+
export const audioState = {
|
|
1194
|
+
currentSong,
|
|
1195
|
+
currentPlaylist,
|
|
1196
|
+
playlistQueue,
|
|
1197
|
+
isPlaying,
|
|
1198
|
+
currentTime,
|
|
1199
|
+
duration,
|
|
1200
|
+
currentIndex,
|
|
1201
|
+
volume,
|
|
1202
|
+
isLoading,
|
|
1203
|
+
loadingSongIds,
|
|
1204
|
+
selectedSongId,
|
|
1205
|
+
preloadingSongId,
|
|
1206
|
+
repeatMode,
|
|
1207
|
+
isShuffled,
|
|
1208
|
+
downloadProgress,
|
|
1209
|
+
cachingSongIds,
|
|
1210
|
+
songPlaybackPositions,
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
// cleanup function
|
|
1214
|
+
export function cleanup(): void {
|
|
1215
|
+
stop();
|
|
1216
|
+
|
|
1217
|
+
const audio = audioElement;
|
|
1218
|
+
if (audio) {
|
|
1219
|
+
// remove all event listeners
|
|
1220
|
+
audio.removeEventListener("loadstart", () => {});
|
|
1221
|
+
audio.removeEventListener("canplay", () => {});
|
|
1222
|
+
audio.removeEventListener("loadedmetadata", () => {});
|
|
1223
|
+
audio.removeEventListener("timeupdate", () => {});
|
|
1224
|
+
audio.removeEventListener("play", () => {});
|
|
1225
|
+
audio.removeEventListener("pause", () => {});
|
|
1226
|
+
audio.removeEventListener("ended", () => {});
|
|
1227
|
+
audio.removeEventListener("error", () => {});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
audioElement = null;
|
|
1231
|
+
|
|
1232
|
+
// clear queue state
|
|
1233
|
+
setPlaylistQueue([]);
|
|
1234
|
+
setCurrentIndex(-1);
|
|
1235
|
+
setRepeatMode("none");
|
|
1236
|
+
setIsShuffled(false);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// helper to preload next song in background
|
|
1240
|
+
async function preloadNextSong(): Promise<void> {
|
|
1241
|
+
const queue = playlistQueue();
|
|
1242
|
+
const currentIdx = currentIndex();
|
|
1243
|
+
|
|
1244
|
+
if (queue.length === 0 || currentIdx < 0) return;
|
|
1245
|
+
|
|
1246
|
+
const nextIndex = currentIdx + 1;
|
|
1247
|
+
if (nextIndex >= queue.length) return; // no next song
|
|
1248
|
+
|
|
1249
|
+
const nextSong = queue[nextIndex];
|
|
1250
|
+
if (!nextSong) return;
|
|
1251
|
+
|
|
1252
|
+
// don't preload if already loading or preloaded
|
|
1253
|
+
if (loadingSongIds().has(nextSong.id) || preloadingSongId() === nextSong.id) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
setPreloadingSongId(nextSong.id);
|
|
1258
|
+
setLoadingSongIds((prev) => new Set(Array.from(prev).concat([nextSong.id])));
|
|
1259
|
+
|
|
1260
|
+
try {
|
|
1261
|
+
// check if song already has audio cached in the blob store
|
|
1262
|
+
const blobUrl = (nextSong.sha ?? nextSong.sha256)
|
|
1263
|
+
? await getBlobObjectURL((nextSong.sha ?? nextSong.sha256)!)
|
|
1264
|
+
: null;
|
|
1265
|
+
if (blobUrl) {
|
|
1266
|
+
setLoadingSongIds((prev) => {
|
|
1267
|
+
const newSet = new Set(prev);
|
|
1268
|
+
newSet.delete(nextSong.id);
|
|
1269
|
+
return newSet;
|
|
1270
|
+
});
|
|
1271
|
+
setPreloadingSongId(null);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// check if song is already being downloaded
|
|
1276
|
+
if (isSongDownloading(nextSong.id)) {
|
|
1277
|
+
setLoadingSongIds((prev) => {
|
|
1278
|
+
const newSet = new Set(prev);
|
|
1279
|
+
newSet.delete(nextSong.id);
|
|
1280
|
+
return newSet;
|
|
1281
|
+
});
|
|
1282
|
+
setPreloadingSongId(null);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (nextSong.standaloneFilePath) {
|
|
1287
|
+
// track that this song is being cached for preloading
|
|
1288
|
+
setCachingSongIds(
|
|
1289
|
+
(prev) => new Set(Array.from(prev).concat([nextSong.id]))
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
// start preload download
|
|
1293
|
+
downloadSongIfNeeded(
|
|
1294
|
+
nextSong,
|
|
1295
|
+
nextSong.standaloneFilePath,
|
|
1296
|
+
(progress) => {
|
|
1297
|
+
// update preload progress tracking
|
|
1298
|
+
setDownloadProgress((prev) => {
|
|
1299
|
+
const newMap = new Map(prev);
|
|
1300
|
+
newMap.set(nextSong.id, progress.percentage);
|
|
1301
|
+
return newMap;
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
)
|
|
1305
|
+
.then((success) => {
|
|
1306
|
+
if (success) {
|
|
1307
|
+
console.debug(`successfully preloaded ${nextSong.title}`);
|
|
1308
|
+
} else {
|
|
1309
|
+
console.warn(`failed to preload ${nextSong.title}`);
|
|
1310
|
+
}
|
|
1311
|
+
})
|
|
1312
|
+
.catch((error) => {
|
|
1313
|
+
console.error(`error preloading ${nextSong.title}:`, error);
|
|
1314
|
+
})
|
|
1315
|
+
.finally(() => {
|
|
1316
|
+
// clean up preload progress tracking
|
|
1317
|
+
setDownloadProgress((prev) => {
|
|
1318
|
+
const newMap = new Map(prev);
|
|
1319
|
+
newMap.delete(nextSong.id);
|
|
1320
|
+
return newMap;
|
|
1321
|
+
});
|
|
1322
|
+
setCachingSongIds((prev) => {
|
|
1323
|
+
const newSet = new Set(prev);
|
|
1324
|
+
newSet.delete(nextSong.id);
|
|
1325
|
+
return newSet;
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
} catch {
|
|
1330
|
+
// ignore errors when clearing cache
|
|
1331
|
+
} finally {
|
|
1332
|
+
// always clean up loading state for preloading
|
|
1333
|
+
setLoadingSongIds((prev) => {
|
|
1334
|
+
const newSet = new Set(prev);
|
|
1335
|
+
newSet.delete(nextSong.id);
|
|
1336
|
+
return newSet;
|
|
1337
|
+
});
|
|
1338
|
+
setPreloadingSongId(null);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// helper to select a song to play (sets immediate ui feedback)
|
|
1343
|
+
export function selectSong(songId: string): void {
|
|
1344
|
+
// pause current audio immediately
|
|
1345
|
+
const audio = audioElement;
|
|
1346
|
+
if (audio) {
|
|
1347
|
+
audio.pause();
|
|
1348
|
+
setIsPlaying(false);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// set this as the selected song
|
|
1352
|
+
setSelectedSongId(songId);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// clear the selected song
|
|
1356
|
+
export function clearSelectedSong(): void {
|
|
1357
|
+
setSelectedSongId(null);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// helper functions for streaming downloadz
|
|
1361
|
+
export function getSongDownloadProgress(songId: string): number {
|
|
1362
|
+
return downloadProgress().get(songId) || 0;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
export function isSongCaching(songId: string): boolean {
|
|
1366
|
+
return cachingSongIds().has(songId);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// --- dev hook points (implementations registered in src/dev-hooks.ts) ---
|
|
1370
|
+
|
|
1371
|
+
// seek the current audio element to a specific time in seconds.
|
|
1372
|
+
export function _devSeekTo(seconds: number): void {
|
|
1373
|
+
if (audioElement) audioElement.currentTime = seconds;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// fire the "ended" event on the audio element as if the track finished.
|
|
1377
|
+
export function _devTriggerTrackEnd(): void {
|
|
1378
|
+
audioElement?.dispatchEvent(new Event("ended"));
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// fire an "error" event on the audio element. code defaults to
|
|
1382
|
+
// MEDIA_ERR_SRC_NOT_SUPPORTED (4).
|
|
1383
|
+
export function _devTriggerAudioError(code = 4): void {
|
|
1384
|
+
if (!audioElement) return;
|
|
1385
|
+
const err = { code, message: "test-injected error" };
|
|
1386
|
+
Object.defineProperty(audioElement, "error", {
|
|
1387
|
+
get: () => err,
|
|
1388
|
+
configurable: true,
|
|
1389
|
+
});
|
|
1390
|
+
audioElement.dispatchEvent(new Event("error"));
|
|
1391
|
+
Object.defineProperty(audioElement, "error", {
|
|
1392
|
+
get: undefined,
|
|
1393
|
+
configurable: true,
|
|
1394
|
+
});
|
|
1395
|
+
}
|