@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.
Files changed (180) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.changeset/nice-wolves-thank.md +5 -0
  3. package/.freqhole-versions.json +4 -0
  4. package/.github/copilot-instructions.md +201 -0
  5. package/.github/workflows/changesets.yml +50 -0
  6. package/.github/workflows/npm-publish.yml +124 -0
  7. package/.github/workflows/pr-checks.yml +103 -0
  8. package/README.md +30 -0
  9. package/build-component.js +141 -0
  10. package/build-zip-bundle-lib.js +44 -0
  11. package/config/playwright.config.ts +47 -0
  12. package/config/vite.config.ts +44 -0
  13. package/config/vitest.config.ts +39 -0
  14. package/dist/assets/automerge_wasm_bg-Cik4BF9l.wasm +0 -0
  15. package/dist/assets/index-CbOXzGiA.js +216 -0
  16. package/dist/assets/index-CbOXzGiA.js.map +1 -0
  17. package/dist/assets/index-TvJ6RFpy.css +1 -0
  18. package/dist/assets/midden-DceCrT_L.js +2 -0
  19. package/dist/assets/midden-DceCrT_L.js.map +1 -0
  20. package/dist/assets/midden_bg-BLhfGIU-.wasm +0 -0
  21. package/dist/index.html +55 -0
  22. package/dist/sw.js +134 -0
  23. package/docs/AUTOMERGE_P2P_PLAN.md +233 -0
  24. package/docs/COLLABORATIVE_SHARING_PLAN.md +188 -0
  25. package/docs/E2E_TESTID_PLAN.md +234 -0
  26. package/docs/IROH_P2P_PLAN.md +302 -0
  27. package/docs/ROADMAP.md +695 -0
  28. package/docs/TODO.md +167 -0
  29. package/docs/bundle-embedding-plan.md +134 -0
  30. package/docs/standalone-refactor.md +184 -0
  31. package/e2e/all-playlists.spec.ts +220 -0
  32. package/e2e/audio-player.spec.ts +226 -0
  33. package/e2e/collaborative-features.spec.ts +229 -0
  34. package/e2e/contexts.ts +238 -0
  35. package/e2e/edit-panel.spec.ts +87 -0
  36. package/e2e/fixtures/bare-glitch-1s.m4a +0 -0
  37. package/e2e/fixtures/bare-glitch-1s.mp3 +0 -0
  38. package/e2e/fixtures/bare-glitch-1s.ogg +0 -0
  39. package/e2e/fixtures/chord-stack-3s.wav +0 -0
  40. package/e2e/fixtures/cover-anim.gif +0 -0
  41. package/e2e/fixtures/cover-blue.png +0 -0
  42. package/e2e/fixtures/cover-checkers.png +0 -0
  43. package/e2e/fixtures/cover-gradient.jpg +0 -0
  44. package/e2e/fixtures/cover-mono.gif +0 -0
  45. package/e2e/fixtures/cover-noise.png +0 -0
  46. package/e2e/fixtures/cover-plasma.webp +0 -0
  47. package/e2e/fixtures/cover-portrait.jpg +0 -0
  48. package/e2e/fixtures/cover-red.png +0 -0
  49. package/e2e/fixtures/cover-thumb.jpg +0 -0
  50. package/e2e/fixtures/cover-wide.webp +0 -0
  51. package/e2e/fixtures/generate.mjs +257 -0
  52. package/e2e/fixtures/long-drone-90s.mp3 +0 -0
  53. package/e2e/fixtures/noisy-binaural-8s.mp3 +0 -0
  54. package/e2e/fixtures/tagged-a3-4s.m4a +0 -0
  55. package/e2e/fixtures/tagged-a3-4s.mp3 +0 -0
  56. package/e2e/fixtures/tagged-a3-4s.ogg +0 -0
  57. package/e2e/fixtures/tagged-c5-3s.m4a +0 -0
  58. package/e2e/fixtures/tagged-c5-3s.mp3 +0 -0
  59. package/e2e/fixtures/tagged-c5-3s.ogg +0 -0
  60. package/e2e/fixtures/tagged-f4-6s.m4a +0 -0
  61. package/e2e/fixtures/tagged-f4-6s.mp3 +0 -0
  62. package/e2e/fixtures/tagged-f4-6s.ogg +0 -0
  63. package/e2e/fixtures/tone-220hz-10s.wav +0 -0
  64. package/e2e/fixtures/tone-440hz-2s.wav +0 -0
  65. package/e2e/fixtures/tone-880hz-5s.wav +0 -0
  66. package/e2e/fixtures/tone-stereo-3s.wav +0 -0
  67. package/e2e/fixtures/user-provided/README.md +1 -0
  68. package/e2e/helpers/app.ts +143 -0
  69. package/e2e/helpers/hooks.ts +133 -0
  70. package/e2e/helpers/index.ts +12 -0
  71. package/e2e/helpers/media.ts +125 -0
  72. package/e2e/helpers.ts +10 -0
  73. package/e2e/p2p-collaboration.spec.ts +356 -0
  74. package/e2e/p2p-multi-peer.spec.ts +723 -0
  75. package/e2e/p2p-states.spec.ts +302 -0
  76. package/e2e/playback.spec.ts +56 -0
  77. package/e2e/playlist-crud.spec.ts +126 -0
  78. package/e2e/share-link-autoplay.spec.ts +129 -0
  79. package/e2e/sharing-access.spec.ts +205 -0
  80. package/e2e/sharing.spec.ts +195 -0
  81. package/e2e/song-cache-state.spec.ts +202 -0
  82. package/e2e/zip-bundle.spec.ts +855 -0
  83. package/eslint.config.js +114 -0
  84. package/index.html +54 -0
  85. package/package.json +119 -0
  86. package/public/sw.js +134 -0
  87. package/scripts/use-local.mjs +37 -0
  88. package/scripts/use-published.mjs +37 -0
  89. package/src/App.tsx +9 -0
  90. package/src/cli/check.ts +164 -0
  91. package/src/cli/generate.ts +184 -0
  92. package/src/cli/http.ts +88 -0
  93. package/src/cli/index.ts +65 -0
  94. package/src/cli/init.ts +18 -0
  95. package/src/components/AllPlaylistsPanel.tsx +713 -0
  96. package/src/components/AudioPlayer.tsx +122 -0
  97. package/src/components/MarqueeText.tsx +101 -0
  98. package/src/components/PlaylistCoverModal.tsx +519 -0
  99. package/src/components/PlaylistEditPanel.tsx +803 -0
  100. package/src/components/PlaylistSharePanel.tsx +1020 -0
  101. package/src/components/ShareLinkKnockPanel.tsx +144 -0
  102. package/src/components/SharePanel.tsx +584 -0
  103. package/src/components/SongEditModal.tsx +453 -0
  104. package/src/components/SongEditPanel.tsx +578 -0
  105. package/src/components/SongRow.tsx +689 -0
  106. package/src/components/index.tsx +494 -0
  107. package/src/components/playlist/index.tsx +1203 -0
  108. package/src/context/PlaylistzContext.tsx +74 -0
  109. package/src/dev-hooks.ts +35 -0
  110. package/src/hooks/createDocIndexQuery.ts +53 -0
  111. package/src/hooks/createDocStore.test.ts +303 -0
  112. package/src/hooks/createDocStore.ts +90 -0
  113. package/src/hooks/useDragAndDrop.test.ts +474 -0
  114. package/src/hooks/useDragAndDrop.ts +400 -0
  115. package/src/hooks/useImageModal.test.ts +174 -0
  116. package/src/hooks/useImageModal.ts +201 -0
  117. package/src/hooks/usePlaylistManager.test.ts +453 -0
  118. package/src/hooks/usePlaylistManager.ts +685 -0
  119. package/src/hooks/usePlaylistsQuery.test.tsx +120 -0
  120. package/src/hooks/usePlaylistsQuery.ts +44 -0
  121. package/src/hooks/useSongState.test.ts +236 -0
  122. package/src/hooks/useSongState.ts +114 -0
  123. package/src/hooks/useUIState.ts +71 -0
  124. package/src/index.tsx +18 -0
  125. package/src/services/audioService.dev.ts +22 -0
  126. package/src/services/audioService.test.ts +1226 -0
  127. package/src/services/audioService.ts +1395 -0
  128. package/src/services/automergeRepo.test.ts +269 -0
  129. package/src/services/automergeRepo.ts +226 -0
  130. package/src/services/blobTransferService.dev.ts +119 -0
  131. package/src/services/blobTransferService.test.ts +441 -0
  132. package/src/services/blobTransferService.ts +702 -0
  133. package/src/services/docIndexService.test.ts +179 -0
  134. package/src/services/docIndexService.ts +118 -0
  135. package/src/services/fileProcessingService.test.ts +554 -0
  136. package/src/services/fileProcessingService.ts +239 -0
  137. package/src/services/imageService.test.ts +701 -0
  138. package/src/services/imageService.ts +365 -0
  139. package/src/services/indexedDBService.integration.test.ts +104 -0
  140. package/src/services/indexedDBService.test.ts +202 -0
  141. package/src/services/indexedDBService.ts +436 -0
  142. package/src/services/offlineService.test.ts +661 -0
  143. package/src/services/offlineService.ts +382 -0
  144. package/src/services/p2pService.test.ts +305 -0
  145. package/src/services/p2pService.ts +344 -0
  146. package/src/services/playlistDocService.test.ts +448 -0
  147. package/src/services/playlistDocService.ts +707 -0
  148. package/src/services/playlistDownloadService.test.ts +674 -0
  149. package/src/services/playlistDownloadService.ts +389 -0
  150. package/src/services/sharingService.test.ts +812 -0
  151. package/src/services/sharingService.ts +1073 -0
  152. package/src/services/sharingState.ts +161 -0
  153. package/src/services/songReactivity.test.ts +620 -0
  154. package/src/services/songReactivity.ts +145 -0
  155. package/src/services/standaloneService.test.ts +1025 -0
  156. package/src/services/standaloneService.ts +588 -0
  157. package/src/services/streamingAudioService.test.ts +275 -0
  158. package/src/services/streamingAudioService.ts +166 -0
  159. package/src/styles.css +428 -0
  160. package/src/test-setup.ts +547 -0
  161. package/src/types/global.d.ts +40 -0
  162. package/src/types/playlist.ts +99 -0
  163. package/src/utils/hashUtils.ts +41 -0
  164. package/src/utils/log.ts +97 -0
  165. package/src/utils/m3u.test.ts +172 -0
  166. package/src/utils/m3u.ts +136 -0
  167. package/src/utils/mockData.ts +166 -0
  168. package/src/utils/standaloneTemplates.test.ts +175 -0
  169. package/src/utils/standaloneTemplates.ts +83 -0
  170. package/src/utils/swTemplate.ts +84 -0
  171. package/src/utils/timeUtils.ts +166 -0
  172. package/src/utils/typeGuards.ts +171 -0
  173. package/src/web-component.tsx +98 -0
  174. package/src/zip-bundle/index.ts +7 -0
  175. package/src/zip-bundle/m3u.ts +45 -0
  176. package/src/zip-bundle/types.ts +50 -0
  177. package/src/zip-bundle/utils.ts +33 -0
  178. package/src/zip-bundle/zipBuilder.ts +309 -0
  179. package/tailwind.config.js +55 -0
  180. 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
+ }