pwa-audio-player 0.0.0-stage → 1.0.0

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/dist/index.cjs ADDED
@@ -0,0 +1,1035 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ GestureGuardian: () => GestureGuardian,
24
+ MediaSessionController: () => MediaSessionController,
25
+ PlaylistManager: () => PlaylistManager,
26
+ PwaPlayer: () => PwaPlayer,
27
+ SilentHoldKeeper: () => SilentHoldKeeper,
28
+ createPwaPlayer: () => createPwaPlayer,
29
+ detectPlatform: () => detectPlatform,
30
+ getActiveLyric: () => getActiveLyric,
31
+ getSilentWavUrl: () => getSilentWavUrl,
32
+ parseLrc: () => parseLrc,
33
+ platform: () => platform
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/platform.ts
38
+ function detectPlatform() {
39
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
40
+ return {
41
+ isIOS: false,
42
+ isSafari: false,
43
+ isStandalone: false,
44
+ supportsMediaSession: false,
45
+ supportsAudioSession: false
46
+ };
47
+ }
48
+ const ua = navigator.userAgent;
49
+ const isIOS = /iPad|iPhone|iPod/.test(ua) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
50
+ const isSafari = /^((?!chrome|android).)*safari/i.test(ua) || /AppleWebKit/i.test(ua) && !/Chrome|CriOS|FxiOS/i.test(ua);
51
+ const isStandalone = "standalone" in window.navigator && Boolean(window.navigator.standalone) || window.matchMedia("(display-mode: standalone)").matches;
52
+ const supportsMediaSession = "mediaSession" in navigator;
53
+ const supportsAudioSession = "audioSession" in navigator;
54
+ return {
55
+ isIOS,
56
+ isSafari,
57
+ isStandalone,
58
+ supportsMediaSession,
59
+ supportsAudioSession
60
+ };
61
+ }
62
+ var platform = detectPlatform();
63
+
64
+ // src/silentWav.ts
65
+ var cachedSilentWavUrl = null;
66
+ function getSilentWavUrl() {
67
+ if (cachedSilentWavUrl) {
68
+ return cachedSilentWavUrl;
69
+ }
70
+ if (typeof window === "undefined" || typeof Blob === "undefined") {
71
+ return "";
72
+ }
73
+ try {
74
+ const sampleRate = 8e3;
75
+ const durationSec = 2;
76
+ const numSamples = sampleRate * durationSec;
77
+ const buffer = new ArrayBuffer(44 + numSamples * 2);
78
+ const view = new DataView(buffer);
79
+ const writeString = (offset, str) => {
80
+ for (let i = 0; i < str.length; i++) {
81
+ view.setUint8(offset + i, str.charCodeAt(i));
82
+ }
83
+ };
84
+ writeString(0, "RIFF");
85
+ view.setUint32(4, 36 + numSamples * 2, true);
86
+ writeString(8, "WAVE");
87
+ writeString(12, "fmt ");
88
+ view.setUint32(16, 16, true);
89
+ view.setUint16(20, 1, true);
90
+ view.setUint16(22, 1, true);
91
+ view.setUint32(24, sampleRate, true);
92
+ view.setUint32(28, sampleRate * 2, true);
93
+ view.setUint16(32, 2, true);
94
+ view.setUint16(34, 16, true);
95
+ writeString(36, "data");
96
+ view.setUint32(40, numSamples * 2, true);
97
+ const blob = new Blob([buffer], { type: "audio/wav" });
98
+ cachedSilentWavUrl = URL.createObjectURL(blob);
99
+ return cachedSilentWavUrl;
100
+ } catch (err) {
101
+ console.error("[PwaPlayer:SilentWav] Failed to construct silent WAV blob:", err);
102
+ return "";
103
+ }
104
+ }
105
+
106
+ // src/keepAlive.ts
107
+ var SilentHoldKeeper = class {
108
+ audioEl;
109
+ options;
110
+ events;
111
+ holding = false;
112
+ savedPauseTime = 0;
113
+ savedDuration = 0;
114
+ savedSongSrc = "";
115
+ savedTrack = null;
116
+ timeoutTimer = null;
117
+ constructor(audioEl, options = {}, events = {}) {
118
+ this.audioEl = audioEl;
119
+ this.options = {
120
+ strategy: options.strategy ?? "auto",
121
+ hardPauseTimeoutMs: options.hardPauseTimeoutMs ?? 5 * 60 * 1e3
122
+ };
123
+ this.events = events;
124
+ }
125
+ shouldActivateSilentHold() {
126
+ const strat = this.options.strategy;
127
+ if (strat === "silent-hold") return true;
128
+ if (strat === "native") return false;
129
+ return platform.isIOS;
130
+ }
131
+ isHolding() {
132
+ return this.holding;
133
+ }
134
+ getSavedPauseTime() {
135
+ return this.savedPauseTime;
136
+ }
137
+ updateSavedPauseTime(time) {
138
+ if (!this.holding) return;
139
+ this.savedPauseTime = Math.max(0, this.savedDuration > 0 ? Math.min(time, this.savedDuration) : time);
140
+ }
141
+ /**
142
+ * Intercept pause action.
143
+ * @returns true if handled by silent-hold keeper (caller MUST NOT call audio.pause())
144
+ */
145
+ handlePause(track, currentTime, duration, currentSrc) {
146
+ if (!this.shouldActivateSilentHold()) {
147
+ return false;
148
+ }
149
+ if (this.holding) {
150
+ console.info("[PwaPlayer:KeepAlive] AirPods Flip-Flop: Inverting pause action to resume.");
151
+ this.handleResume();
152
+ return true;
153
+ }
154
+ const songUrl = currentSrc || this.audioEl.src;
155
+ if (!songUrl || songUrl === window.location.href || songUrl.startsWith("blob:")) {
156
+ return false;
157
+ }
158
+ this.savedPauseTime = currentTime || this.audioEl.currentTime || 0;
159
+ this.savedDuration = duration > 0 && isFinite(duration) ? duration : this.audioEl.duration || 0;
160
+ this.savedSongSrc = songUrl;
161
+ this.savedTrack = track;
162
+ this.holding = true;
163
+ this.clearTimeoutTimer();
164
+ const silentUrl = getSilentWavUrl();
165
+ this.audioEl.loop = true;
166
+ this.audioEl.src = silentUrl;
167
+ this.audioEl.play().then(() => {
168
+ this.events.onKeepAliveEnter?.();
169
+ }).catch((err) => {
170
+ console.warn("[PwaPlayer:KeepAlive] Silent WAV play failed, falling back to native pause:", err);
171
+ this.reset();
172
+ try {
173
+ this.audioEl.pause();
174
+ } catch {
175
+ }
176
+ });
177
+ if (this.options.hardPauseTimeoutMs > 0) {
178
+ this.timeoutTimer = setTimeout(() => {
179
+ if (this.holding) {
180
+ console.info("[PwaPlayer:KeepAlive] Inactivity timeout reached. Executing hard pause to conserve power.");
181
+ this.reset();
182
+ try {
183
+ this.audioEl.pause();
184
+ } catch {
185
+ }
186
+ this.events.onHardPauseTimeout?.();
187
+ }
188
+ }, this.options.hardPauseTimeoutMs);
189
+ if (typeof this.timeoutTimer?.unref === "function") {
190
+ this.timeoutTimer.unref();
191
+ }
192
+ }
193
+ return true;
194
+ }
195
+ /**
196
+ * Intercept resume action.
197
+ * @returns true if handled by silent-hold recovery
198
+ */
199
+ handleResume(onPlaySuccess) {
200
+ if (!this.holding) {
201
+ return false;
202
+ }
203
+ this.clearTimeoutTimer();
204
+ const targetTime = this.savedPauseTime;
205
+ const songSrc = this.savedSongSrc;
206
+ this.holding = false;
207
+ this.audioEl.loop = false;
208
+ if (!songSrc || songSrc === window.location.href) {
209
+ return false;
210
+ }
211
+ this.audioEl.src = songSrc;
212
+ this.audioEl.play().then(() => {
213
+ const doSeek = () => {
214
+ try {
215
+ if (targetTime > 0) {
216
+ this.audioEl.currentTime = targetTime;
217
+ }
218
+ } catch (e) {
219
+ console.warn("[PwaPlayer:KeepAlive] Breakpoint seek failed:", e);
220
+ }
221
+ this.events.onKeepAliveExit?.();
222
+ onPlaySuccess?.();
223
+ };
224
+ if (this.audioEl.readyState >= 1 && this.audioEl.duration > 0) {
225
+ setTimeout(doSeek, 50);
226
+ } else {
227
+ const onMetadata = () => {
228
+ this.audioEl.removeEventListener("loadedmetadata", onMetadata);
229
+ setTimeout(doSeek, 50);
230
+ };
231
+ this.audioEl.addEventListener("loadedmetadata", onMetadata);
232
+ }
233
+ }).catch((err) => {
234
+ console.error("[PwaPlayer:KeepAlive] Track resume failed:", err);
235
+ this.holding = false;
236
+ this.audioEl.loop = false;
237
+ });
238
+ return true;
239
+ }
240
+ reset() {
241
+ this.clearTimeoutTimer();
242
+ this.holding = false;
243
+ this.savedPauseTime = 0;
244
+ this.savedDuration = 0;
245
+ this.savedSongSrc = "";
246
+ this.savedTrack = null;
247
+ try {
248
+ this.audioEl.loop = false;
249
+ } catch {
250
+ }
251
+ }
252
+ clearTimeoutTimer() {
253
+ if (this.timeoutTimer) {
254
+ clearTimeout(this.timeoutTimer);
255
+ this.timeoutTimer = null;
256
+ }
257
+ }
258
+ };
259
+
260
+ // src/mediaSession.ts
261
+ var MediaSessionController = class {
262
+ callbacks;
263
+ enforceNavigation;
264
+ lastSyncedTrackId = null;
265
+ lastSyncedLyricText = null;
266
+ constructor(callbacks, enforceNavigation = true) {
267
+ this.callbacks = callbacks;
268
+ this.enforceNavigation = enforceNavigation;
269
+ }
270
+ isSupported() {
271
+ return typeof window !== "undefined" && "mediaSession" in navigator;
272
+ }
273
+ registerHandlers() {
274
+ if (!this.isSupported()) return;
275
+ try {
276
+ navigator.mediaSession.setActionHandler("play", () => this.callbacks.onPlay());
277
+ navigator.mediaSession.setActionHandler("pause", () => this.callbacks.onPause());
278
+ navigator.mediaSession.setActionHandler("previoustrack", () => this.callbacks.onPrev());
279
+ navigator.mediaSession.setActionHandler("nexttrack", () => this.callbacks.onNext());
280
+ if (this.enforceNavigation) {
281
+ try {
282
+ navigator.mediaSession.setActionHandler("seekbackward", null);
283
+ } catch {
284
+ }
285
+ try {
286
+ navigator.mediaSession.setActionHandler("seekforward", null);
287
+ } catch {
288
+ }
289
+ try {
290
+ navigator.mediaSession.setActionHandler("seekto", null);
291
+ } catch {
292
+ }
293
+ }
294
+ } catch (e) {
295
+ console.warn("[PwaPlayer:MediaSession] Failed to register action handlers:", e);
296
+ }
297
+ }
298
+ updatePlaybackState(state) {
299
+ if (!this.isSupported()) return;
300
+ try {
301
+ navigator.mediaSession.playbackState = state;
302
+ } catch {
303
+ }
304
+ }
305
+ updateMetadata(track, lyricInfo) {
306
+ if (!this.isSupported()) return;
307
+ if (!track) {
308
+ navigator.mediaSession.metadata = null;
309
+ this.lastSyncedTrackId = null;
310
+ this.lastSyncedLyricText = null;
311
+ return;
312
+ }
313
+ const currentLine = lyricInfo?.currentLine?.trim();
314
+ const nextLine = lyricInfo?.nextLine?.trim();
315
+ const title = currentLine || track.title;
316
+ const artist = currentLine ? `${track.title}${track.artist ? ` \xB7 ${track.artist}` : ""}` : track.artist || "Unknown Artist";
317
+ const album = nextLine ? `\u23ED ${nextLine}` : track.album || track.title;
318
+ if (navigator.mediaSession.metadata && this.lastSyncedTrackId === track.id && this.lastSyncedLyricText === currentLine) {
319
+ return;
320
+ }
321
+ this.lastSyncedTrackId = track.id;
322
+ this.lastSyncedLyricText = currentLine ?? null;
323
+ try {
324
+ navigator.mediaSession.metadata = new MediaMetadata({
325
+ title,
326
+ artist,
327
+ album,
328
+ artwork: this.normalizeArtwork(track.artwork)
329
+ });
330
+ } catch (err) {
331
+ console.warn("[PwaPlayer:MediaSession] Failed to update MediaMetadata:", err);
332
+ }
333
+ }
334
+ updatePosition(position, duration, playbackRate = 1) {
335
+ if (!this.isSupported() || !("setPositionState" in navigator.mediaSession)) return;
336
+ if (duration > 0 && isFinite(duration)) {
337
+ try {
338
+ navigator.mediaSession.setPositionState({
339
+ duration,
340
+ playbackRate,
341
+ position: Math.min(Math.max(0, position), duration)
342
+ });
343
+ } catch (err) {
344
+ console.warn("[PwaPlayer:MediaSession] setPositionState failed:", err);
345
+ }
346
+ }
347
+ }
348
+ clearPosition() {
349
+ if (!this.isSupported() || !("setPositionState" in navigator.mediaSession)) return;
350
+ try {
351
+ navigator.mediaSession.setPositionState();
352
+ } catch {
353
+ try {
354
+ navigator.mediaSession.setPositionState(null);
355
+ } catch {
356
+ }
357
+ }
358
+ }
359
+ /**
360
+ * Apply Triple-Lock UI Masking during silent-hold keep-alive:
361
+ * 1. Mask playbackState as 'paused' so the OS displays ▶️ and stops fake progress calculation
362
+ * 2. Retain current track metadata (title, artist, artwork) so lockscreen card remains intact
363
+ * 3. Freeze setPositionState at exact breakpoint position
364
+ */
365
+ applyTripleInvisibleLock(track, duration, position) {
366
+ if (!this.isSupported()) return;
367
+ this.updatePlaybackState("paused");
368
+ if (track) {
369
+ this.updateMetadata(track);
370
+ }
371
+ this.updatePosition(position, duration, 1);
372
+ }
373
+ normalizeArtwork(artwork) {
374
+ if (Array.isArray(artwork)) {
375
+ return artwork;
376
+ }
377
+ const defaultUrl = artwork || "";
378
+ if (!defaultUrl) return [];
379
+ let resolvedUrl = defaultUrl;
380
+ try {
381
+ resolvedUrl = new URL(defaultUrl, window.location.href).href;
382
+ } catch {
383
+ }
384
+ return [
385
+ { src: resolvedUrl, sizes: "96x96" },
386
+ { src: resolvedUrl, sizes: "128x128" },
387
+ { src: resolvedUrl, sizes: "192x192" },
388
+ { src: resolvedUrl, sizes: "256x256" },
389
+ { src: resolvedUrl, sizes: "384x384" },
390
+ { src: resolvedUrl, sizes: "512x512" }
391
+ ];
392
+ }
393
+ };
394
+
395
+ // src/playlist.ts
396
+ var PlaylistManager = class {
397
+ queue = [];
398
+ currentIndex = 0;
399
+ playMode = "sequence";
400
+ shuffleIndices = [];
401
+ shufflePointer = 0;
402
+ constructor(initialQueue = [], initialIndex = 0, initialMode = "sequence") {
403
+ this.queue = [...initialQueue];
404
+ this.currentIndex = Math.max(0, Math.min(initialIndex, this.queue.length - 1));
405
+ this.playMode = initialMode;
406
+ if (this.playMode === "shuffle") {
407
+ this.rebuildShuffleOrder();
408
+ }
409
+ }
410
+ getQueue() {
411
+ return [...this.queue];
412
+ }
413
+ getCurrentIndex() {
414
+ return this.currentIndex;
415
+ }
416
+ getCurrentTrack() {
417
+ if (this.currentIndex >= 0 && this.currentIndex < this.queue.length) {
418
+ return this.queue[this.currentIndex];
419
+ }
420
+ return null;
421
+ }
422
+ getPlayMode() {
423
+ return this.playMode;
424
+ }
425
+ setPlayMode(mode) {
426
+ this.playMode = mode;
427
+ if (mode === "shuffle") {
428
+ this.rebuildShuffleOrder();
429
+ }
430
+ }
431
+ setQueue(tracks, startIndex = 0) {
432
+ this.queue = [...tracks];
433
+ this.currentIndex = Math.max(0, Math.min(startIndex, this.queue.length - 1));
434
+ if (this.playMode === "shuffle") {
435
+ this.rebuildShuffleOrder();
436
+ }
437
+ }
438
+ setIndex(index) {
439
+ if (index >= 0 && index < this.queue.length) {
440
+ this.currentIndex = index;
441
+ return true;
442
+ }
443
+ return false;
444
+ }
445
+ getNextIndex() {
446
+ if (this.queue.length === 0) return -1;
447
+ if (this.playMode === "single") {
448
+ return this.currentIndex;
449
+ }
450
+ if (this.playMode === "shuffle") {
451
+ if (this.shuffleIndices.length === 0) this.rebuildShuffleOrder();
452
+ const nextPointer = (this.shufflePointer + 1) % this.shuffleIndices.length;
453
+ return this.shuffleIndices[nextPointer];
454
+ }
455
+ if (this.playMode === "loop") {
456
+ return (this.currentIndex + 1) % this.queue.length;
457
+ }
458
+ const next = this.currentIndex + 1;
459
+ return next < this.queue.length ? next : -1;
460
+ }
461
+ getPrevIndex() {
462
+ if (this.queue.length === 0) return -1;
463
+ if (this.playMode === "single") {
464
+ return this.currentIndex;
465
+ }
466
+ if (this.playMode === "shuffle") {
467
+ if (this.shuffleIndices.length === 0) this.rebuildShuffleOrder();
468
+ const prevPointer = (this.shufflePointer - 1 + this.shuffleIndices.length) % this.shuffleIndices.length;
469
+ return this.shuffleIndices[prevPointer];
470
+ }
471
+ if (this.playMode === "loop") {
472
+ return (this.currentIndex - 1 + this.queue.length) % this.queue.length;
473
+ }
474
+ const prev = this.currentIndex - 1;
475
+ return prev >= 0 ? prev : -1;
476
+ }
477
+ stepNext() {
478
+ const nextIdx = this.getNextIndex();
479
+ if (nextIdx >= 0) {
480
+ this.currentIndex = nextIdx;
481
+ if (this.playMode === "shuffle") {
482
+ this.shufflePointer = (this.shufflePointer + 1) % this.shuffleIndices.length;
483
+ }
484
+ return this.getCurrentTrack();
485
+ }
486
+ return null;
487
+ }
488
+ stepPrev() {
489
+ const prevIdx = this.getPrevIndex();
490
+ if (prevIdx >= 0) {
491
+ this.currentIndex = prevIdx;
492
+ if (this.playMode === "shuffle") {
493
+ this.shufflePointer = (this.shufflePointer - 1 + this.shuffleIndices.length) % this.shuffleIndices.length;
494
+ }
495
+ return this.getCurrentTrack();
496
+ }
497
+ return null;
498
+ }
499
+ shuffle() {
500
+ this.rebuildShuffleOrder();
501
+ }
502
+ rebuildShuffleOrder() {
503
+ const len = this.queue.length;
504
+ this.shuffleIndices = Array.from({ length: len }, (_, i) => i);
505
+ for (let i = len - 1; i > 0; i--) {
506
+ const j = Math.floor(Math.random() * (i + 1));
507
+ [this.shuffleIndices[i], this.shuffleIndices[j]] = [this.shuffleIndices[j], this.shuffleIndices[i]];
508
+ }
509
+ const currentPos = this.shuffleIndices.indexOf(this.currentIndex);
510
+ if (currentPos > 0) {
511
+ [this.shuffleIndices[0], this.shuffleIndices[currentPos]] = [this.shuffleIndices[currentPos], this.shuffleIndices[0]];
512
+ }
513
+ this.shufflePointer = 0;
514
+ }
515
+ };
516
+
517
+ // src/gesture.ts
518
+ var GestureGuardian = class {
519
+ preloadedUrls = /* @__PURE__ */ new Set();
520
+ /**
521
+ * Preload adjacent tracks (both next and previous) concurrently
522
+ * while the current track is actively playing.
523
+ */
524
+ preloadSurroundingTracks(queue, currentIndex) {
525
+ if (typeof document === "undefined" || !queue.length) return;
526
+ const targets = [];
527
+ const nextIdx = (currentIndex + 1) % queue.length;
528
+ if (nextIdx !== currentIndex && queue[nextIdx]) {
529
+ targets.push(queue[nextIdx]);
530
+ }
531
+ const prevIdx = (currentIndex - 1 + queue.length) % queue.length;
532
+ if (prevIdx !== currentIndex && prevIdx !== nextIdx && queue[prevIdx]) {
533
+ targets.push(queue[prevIdx]);
534
+ }
535
+ for (const track of targets) {
536
+ if (!track.src || this.preloadedUrls.has(track.src)) continue;
537
+ this.warmupAudioUrl(track.src);
538
+ }
539
+ }
540
+ warmupAudioUrl(url) {
541
+ if (!url || url.startsWith("blob:")) return;
542
+ this.preloadedUrls.add(url);
543
+ try {
544
+ const link = document.createElement("link");
545
+ link.rel = "preload";
546
+ link.as = "fetch";
547
+ link.href = url;
548
+ link.crossOrigin = "anonymous";
549
+ document.head.appendChild(link);
550
+ const timer = setTimeout(() => {
551
+ try {
552
+ if (link.parentNode) link.parentNode.removeChild(link);
553
+ } catch {
554
+ }
555
+ }, 3e4);
556
+ if (typeof timer?.unref === "function") {
557
+ timer.unref();
558
+ }
559
+ } catch {
560
+ try {
561
+ fetch(url, { headers: { Range: "bytes=0-32767" } }).catch(() => {
562
+ });
563
+ } catch {
564
+ }
565
+ }
566
+ }
567
+ clear() {
568
+ this.preloadedUrls.clear();
569
+ }
570
+ };
571
+
572
+ // src/lyric.ts
573
+ function parseLrc(lrcText) {
574
+ if (!lrcText || typeof lrcText !== "string") return [];
575
+ const lines = lrcText.split("\n");
576
+ const result = [];
577
+ const timeRegex = /\[(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\]/g;
578
+ for (const line of lines) {
579
+ timeRegex.lastIndex = 0;
580
+ const matches = [];
581
+ let match;
582
+ while ((match = timeRegex.exec(line)) !== null) {
583
+ const min = parseInt(match[1], 10);
584
+ const sec = parseInt(match[2], 10);
585
+ const ms = match[3] ? parseInt(match[3].padEnd(3, "0").slice(0, 3), 10) : 0;
586
+ matches.push(min * 60 + sec + ms / 1e3);
587
+ }
588
+ const text = line.replace(/\[\d{1,2}:\d{1,2}(?:\.\d{1,3})?\]/g, "").trim();
589
+ if (text) {
590
+ for (const t of matches) {
591
+ result.push({ time: t, text });
592
+ }
593
+ }
594
+ }
595
+ return result.sort((a, b) => a.time - b.time);
596
+ }
597
+ function getActiveLyric(lyrics, currentTime) {
598
+ if (!lyrics.length) {
599
+ return { index: -1 };
600
+ }
601
+ let low = 0;
602
+ let high = lyrics.length - 1;
603
+ let matchedIndex = -1;
604
+ while (low <= high) {
605
+ const mid = Math.floor((low + high) / 2);
606
+ if (lyrics[mid].time <= currentTime) {
607
+ matchedIndex = mid;
608
+ low = mid + 1;
609
+ } else {
610
+ high = mid - 1;
611
+ }
612
+ }
613
+ if (matchedIndex === -1) {
614
+ return {
615
+ index: -1,
616
+ nextLine: lyrics[0]?.text
617
+ };
618
+ }
619
+ return {
620
+ index: matchedIndex,
621
+ currentLine: lyrics[matchedIndex]?.text,
622
+ nextLine: lyrics[matchedIndex + 1]?.text
623
+ };
624
+ }
625
+
626
+ // src/player.ts
627
+ var activePlayers = /* @__PURE__ */ new Set();
628
+ var PwaPlayer = class {
629
+ audioEl;
630
+ playlist;
631
+ keepAlive;
632
+ mediaSession;
633
+ gesture;
634
+ isPlaying = false;
635
+ currentTime = 0;
636
+ duration = 0;
637
+ volume = 1;
638
+ listeners = /* @__PURE__ */ new Set();
639
+ parsedLyrics = [];
640
+ activeLyricIndex = -2;
641
+ activeLyricText = "";
642
+ enableLockscreenLyrics;
643
+ preloadAdjacent;
644
+ cachedState = null;
645
+ constructor(options = {}) {
646
+ if (options.audioElement) {
647
+ this.audioEl = options.audioElement;
648
+ } else if (typeof document !== "undefined") {
649
+ this.audioEl = document.createElement("audio");
650
+ this.audioEl.setAttribute("playsinline", "true");
651
+ this.audioEl.preload = "auto";
652
+ } else {
653
+ this.audioEl = {};
654
+ }
655
+ this.enableLockscreenLyrics = options.enableLockscreenLyrics ?? true;
656
+ this.preloadAdjacent = options.preloadAdjacent ?? true;
657
+ this.playlist = new PlaylistManager(
658
+ options.initialQueue ?? [],
659
+ options.initialIndex ?? 0,
660
+ options.initialMode ?? "sequence"
661
+ );
662
+ this.gesture = new GestureGuardian();
663
+ this.keepAlive = new SilentHoldKeeper(
664
+ this.audioEl,
665
+ options.keepAlive,
666
+ {
667
+ onKeepAliveEnter: () => {
668
+ const track = this.playlist.getCurrentTrack();
669
+ this.mediaSession.applyTripleInvisibleLock(
670
+ track,
671
+ this.duration,
672
+ this.keepAlive.getSavedPauseTime()
673
+ );
674
+ this.notify();
675
+ },
676
+ onKeepAliveExit: () => {
677
+ this.mediaSession.updatePlaybackState("playing");
678
+ const track = this.playlist.getCurrentTrack();
679
+ this.mediaSession.updateMetadata(track);
680
+ this.notify();
681
+ },
682
+ onHardPauseTimeout: () => {
683
+ this.isPlaying = false;
684
+ this.mediaSession.updatePlaybackState("paused");
685
+ this.notify();
686
+ }
687
+ }
688
+ );
689
+ this.mediaSession = new MediaSessionController(
690
+ {
691
+ onPlay: () => this.handleMediaSessionPlay(),
692
+ onPause: () => this.pause(),
693
+ onPrev: () => this.prev(),
694
+ onNext: () => this.next()
695
+ },
696
+ options.enforceTrackNavigation ?? true
697
+ );
698
+ this.bindAudioEvents();
699
+ this.mediaSession.registerHandlers();
700
+ const initialTrack = this.playlist.getCurrentTrack();
701
+ if (initialTrack) {
702
+ this.prepareTrack(initialTrack, false);
703
+ }
704
+ activePlayers.add(this);
705
+ }
706
+ // ================= Public Playback Controls =================
707
+ async play() {
708
+ for (const other of activePlayers) {
709
+ if (other !== this && other.isPlaying) {
710
+ other.pause();
711
+ }
712
+ }
713
+ if (this.keepAlive.isHolding()) {
714
+ this.keepAlive.handleResume(() => {
715
+ this.isPlaying = true;
716
+ this.warmupAdjacent();
717
+ this.notify();
718
+ });
719
+ return;
720
+ }
721
+ const currentTrack = this.playlist.getCurrentTrack();
722
+ if (!currentTrack) return;
723
+ if (!this.audioEl.src || this.audioEl.src === window.location.href || this.audioEl.src.startsWith("blob:")) {
724
+ this.prepareTrack(currentTrack, true);
725
+ return;
726
+ }
727
+ try {
728
+ await this.audioEl.play();
729
+ this.isPlaying = true;
730
+ this.warmupAdjacent();
731
+ this.notify();
732
+ } catch (err) {
733
+ console.warn("[PwaPlayer] play() rejected:", err);
734
+ this.isPlaying = false;
735
+ this.notify();
736
+ }
737
+ }
738
+ pause() {
739
+ if (!this.audioEl) return;
740
+ this.currentTime = this.audioEl.currentTime || this.currentTime;
741
+ const handledByKeepAlive = this.keepAlive.handlePause(
742
+ this.playlist.getCurrentTrack(),
743
+ this.currentTime,
744
+ this.duration,
745
+ this.audioEl.src
746
+ );
747
+ if (handledByKeepAlive) {
748
+ this.isPlaying = false;
749
+ this.notify();
750
+ return;
751
+ }
752
+ try {
753
+ this.audioEl.pause();
754
+ } catch {
755
+ }
756
+ this.isPlaying = false;
757
+ this.mediaSession.updatePlaybackState("paused");
758
+ this.notify();
759
+ }
760
+ toggle() {
761
+ if (this.keepAlive.isHolding()) {
762
+ this.play();
763
+ return;
764
+ }
765
+ if (this.isPlaying || !this.audioEl.paused && this.audioEl.currentTime > 0) {
766
+ this.pause();
767
+ } else {
768
+ this.play();
769
+ }
770
+ }
771
+ async next() {
772
+ this.keepAlive.reset();
773
+ this.mediaSession.clearPosition();
774
+ const track = this.playlist.stepNext();
775
+ if (!track) {
776
+ this.pause();
777
+ return;
778
+ }
779
+ this.prepareTrack(track, true);
780
+ }
781
+ async prev() {
782
+ this.keepAlive.reset();
783
+ this.mediaSession.clearPosition();
784
+ const track = this.playlist.stepPrev();
785
+ if (!track) {
786
+ this.pause();
787
+ return;
788
+ }
789
+ this.prepareTrack(track, true);
790
+ }
791
+ seek(seconds) {
792
+ const target = Math.max(0, this.duration > 0 ? Math.min(seconds, this.duration) : seconds);
793
+ this.currentTime = target;
794
+ if (this.keepAlive.isHolding()) {
795
+ this.keepAlive.updateSavedPauseTime(target);
796
+ this.mediaSession.applyTripleInvisibleLock(this.playlist.getCurrentTrack(), this.duration, target);
797
+ this.notify();
798
+ return;
799
+ }
800
+ try {
801
+ this.audioEl.currentTime = target;
802
+ } catch {
803
+ }
804
+ this.mediaSession.updatePosition(target, this.duration);
805
+ this.notify();
806
+ }
807
+ setQueue(tracks, startIndex = 0, autoPlay = true) {
808
+ this.keepAlive.reset();
809
+ this.mediaSession.clearPosition();
810
+ this.playlist.setQueue(tracks, startIndex);
811
+ const track = this.playlist.getCurrentTrack();
812
+ if (track) {
813
+ this.prepareTrack(track, autoPlay);
814
+ } else {
815
+ this.resetSource();
816
+ }
817
+ }
818
+ playTrackByIndex(index) {
819
+ if (this.playlist.setIndex(index)) {
820
+ this.keepAlive.reset();
821
+ this.mediaSession.clearPosition();
822
+ const track = this.playlist.getCurrentTrack();
823
+ if (track) {
824
+ this.prepareTrack(track, true);
825
+ }
826
+ }
827
+ }
828
+ setPlayMode(mode) {
829
+ this.playlist.setPlayMode(mode);
830
+ this.warmupAdjacent();
831
+ this.notify();
832
+ }
833
+ setVolume(val) {
834
+ this.volume = Math.max(0, Math.min(1, val));
835
+ if (this.audioEl) {
836
+ try {
837
+ this.audioEl.volume = this.volume;
838
+ } catch {
839
+ }
840
+ }
841
+ this.notify();
842
+ }
843
+ // ================= State & Subscription =================
844
+ getState() {
845
+ if (!this.cachedState) {
846
+ this.rebuildCachedState();
847
+ }
848
+ return this.cachedState;
849
+ }
850
+ rebuildCachedState() {
851
+ const isHold = this.keepAlive.isHolding();
852
+ this.cachedState = {
853
+ currentTrack: this.playlist.getCurrentTrack(),
854
+ playbackState: this.getPlaybackState(),
855
+ isPlaying: this.isPlaying,
856
+ isHolding: isHold,
857
+ currentTime: this.currentTime,
858
+ duration: this.duration,
859
+ queue: this.playlist.getQueue(),
860
+ queueIndex: this.playlist.getCurrentIndex(),
861
+ playMode: this.playlist.getPlayMode(),
862
+ volume: this.volume,
863
+ activeLyricIndex: this.activeLyricIndex,
864
+ activeLyricText: this.activeLyricText
865
+ };
866
+ }
867
+ subscribe(listener) {
868
+ this.listeners.add(listener);
869
+ listener(this.getState());
870
+ return () => {
871
+ this.listeners.delete(listener);
872
+ };
873
+ }
874
+ destroy() {
875
+ activePlayers.delete(this);
876
+ this.keepAlive.reset();
877
+ this.gesture.clear();
878
+ this.listeners.clear();
879
+ try {
880
+ this.audioEl.pause();
881
+ this.audioEl.src = "";
882
+ } catch {
883
+ }
884
+ }
885
+ // ================= Internal Mechanics =================
886
+ getPlaybackState() {
887
+ if (this.keepAlive.isHolding()) return "holding";
888
+ if (this.isPlaying) return "playing";
889
+ return this.currentTime > 0 ? "paused" : "idle";
890
+ }
891
+ notify() {
892
+ this.rebuildCachedState();
893
+ const state = this.cachedState;
894
+ for (const listener of this.listeners) {
895
+ try {
896
+ listener(state);
897
+ } catch (e) {
898
+ console.error("[PwaPlayer] Listener error:", e);
899
+ }
900
+ }
901
+ }
902
+ prepareTrack(track, autoPlay) {
903
+ this.currentTime = 0;
904
+ this.duration = track.duration || 0;
905
+ this.parsedLyrics = parseLrc(track.lrc);
906
+ this.activeLyricIndex = -2;
907
+ this.activeLyricText = "";
908
+ this.mediaSession.updateMetadata(track);
909
+ this.mediaSession.clearPosition();
910
+ if (this.audioEl) {
911
+ this.audioEl.src = track.src;
912
+ try {
913
+ this.audioEl.currentTime = 0;
914
+ } catch {
915
+ }
916
+ }
917
+ if (autoPlay) {
918
+ this.play();
919
+ } else {
920
+ this.notify();
921
+ }
922
+ }
923
+ resetSource() {
924
+ this.isPlaying = false;
925
+ this.currentTime = 0;
926
+ this.duration = 0;
927
+ try {
928
+ this.audioEl.pause();
929
+ this.audioEl.src = "";
930
+ } catch {
931
+ }
932
+ this.mediaSession.updateMetadata(null);
933
+ this.notify();
934
+ }
935
+ warmupAdjacent() {
936
+ if (this.preloadAdjacent) {
937
+ this.gesture.preloadSurroundingTracks(
938
+ this.playlist.getQueue(),
939
+ this.playlist.getCurrentIndex()
940
+ );
941
+ }
942
+ }
943
+ handleMediaSessionPlay() {
944
+ if (this.keepAlive.isHolding()) {
945
+ this.toggle();
946
+ return;
947
+ }
948
+ if (this.audioEl.paused) {
949
+ this.play();
950
+ }
951
+ }
952
+ bindAudioEvents() {
953
+ if (!this.audioEl || typeof this.audioEl.addEventListener !== "function") return;
954
+ this.audioEl.addEventListener("play", () => {
955
+ if (this.keepAlive.isHolding()) return;
956
+ this.isPlaying = true;
957
+ this.mediaSession.registerHandlers();
958
+ this.mediaSession.updatePlaybackState("playing");
959
+ const track = this.playlist.getCurrentTrack();
960
+ if (track) {
961
+ this.mediaSession.updateMetadata(track);
962
+ }
963
+ this.mediaSession.updatePosition(this.currentTime, this.duration);
964
+ this.warmupAdjacent();
965
+ this.notify();
966
+ });
967
+ this.audioEl.addEventListener("pause", () => {
968
+ if (this.keepAlive.isHolding()) return;
969
+ this.isPlaying = false;
970
+ this.mediaSession.updatePlaybackState("paused");
971
+ this.mediaSession.updatePosition(this.currentTime, this.duration);
972
+ this.notify();
973
+ });
974
+ this.audioEl.addEventListener("timeupdate", () => {
975
+ if (this.keepAlive.isHolding()) return;
976
+ this.currentTime = this.audioEl.currentTime;
977
+ if (this.audioEl.duration && !isNaN(this.audioEl.duration) && isFinite(this.audioEl.duration)) {
978
+ this.duration = this.audioEl.duration;
979
+ }
980
+ if (this.enableLockscreenLyrics && this.parsedLyrics.length > 0) {
981
+ const lyric = getActiveLyric(this.parsedLyrics, this.currentTime);
982
+ if (lyric.index !== this.activeLyricIndex) {
983
+ this.activeLyricIndex = lyric.index;
984
+ this.activeLyricText = lyric.currentLine || "";
985
+ this.mediaSession.updateMetadata(this.playlist.getCurrentTrack(), {
986
+ currentLine: lyric.currentLine,
987
+ nextLine: lyric.nextLine
988
+ });
989
+ }
990
+ }
991
+ this.notify();
992
+ });
993
+ this.audioEl.addEventListener("loadedmetadata", () => {
994
+ if (this.keepAlive.isHolding()) return;
995
+ if (this.audioEl.duration && !isNaN(this.audioEl.duration) && isFinite(this.audioEl.duration)) {
996
+ this.duration = this.audioEl.duration;
997
+ }
998
+ this.mediaSession.updatePosition(this.currentTime, this.duration);
999
+ this.notify();
1000
+ });
1001
+ this.audioEl.addEventListener("seeked", () => {
1002
+ if (this.keepAlive.isHolding()) return;
1003
+ this.currentTime = this.audioEl.currentTime;
1004
+ this.mediaSession.updatePosition(this.currentTime, this.duration);
1005
+ this.notify();
1006
+ });
1007
+ this.audioEl.addEventListener("ended", () => {
1008
+ if (this.keepAlive.isHolding()) return;
1009
+ this.next();
1010
+ });
1011
+ this.audioEl.addEventListener("error", () => {
1012
+ if (this.keepAlive.isHolding()) return;
1013
+ console.warn("[PwaPlayer] Audio element error encountered:", this.audioEl.error);
1014
+ this.isPlaying = false;
1015
+ this.notify();
1016
+ });
1017
+ }
1018
+ };
1019
+ function createPwaPlayer(options) {
1020
+ return new PwaPlayer(options);
1021
+ }
1022
+ // Annotate the CommonJS export names for ESM import in node:
1023
+ 0 && (module.exports = {
1024
+ GestureGuardian,
1025
+ MediaSessionController,
1026
+ PlaylistManager,
1027
+ PwaPlayer,
1028
+ SilentHoldKeeper,
1029
+ createPwaPlayer,
1030
+ detectPlatform,
1031
+ getActiveLyric,
1032
+ getSilentWavUrl,
1033
+ parseLrc,
1034
+ platform
1035
+ });