smooth-player 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.
@@ -0,0 +1,930 @@
1
+ import { TypedEventEmitter } from "./events.js";
2
+ const DEFAULT_ANALYZER = {
3
+ fftSize: 2048,
4
+ smoothingTimeConstant: 0.8,
5
+ minDecibels: -90,
6
+ maxDecibels: -10,
7
+ };
8
+ const DEFAULT_ACCENT_COLOR = "#0ed2a4";
9
+ const DEFAULT_PLAYLIST_ID = "default";
10
+ const DEFAULT_PLAYLIST_TITLE = "My playlist";
11
+ export class SmoothPlayer {
12
+ constructor(options = {}) {
13
+ this.events = new TypedEventEmitter();
14
+ this.playlists = [];
15
+ this.activePlaylistId = null;
16
+ this.currentTrackIndex = -1;
17
+ this.resolvedDuration = Number.NaN;
18
+ this.durationFallbackCache = new Map();
19
+ this.resolvingDurationSrc = null;
20
+ this.on = this.events.on.bind(this.events);
21
+ this.off = this.events.off.bind(this.events);
22
+ if (typeof window === "undefined") {
23
+ throw new Error("SmoothPlayer requires a browser environment.");
24
+ }
25
+ this.audio = options.audio ?? new Audio();
26
+ this.audio.crossOrigin = options.crossOrigin ?? "anonymous";
27
+ this.audio.autoplay = options.autoplay ?? false;
28
+ this.audio.loop = options.loop ?? false;
29
+ this.audio.preload = this.audio.preload || "metadata";
30
+ this.audio.volume = this.clamp(options.initialVolume ?? 1);
31
+ this.visualizerMode = options.visualizer ?? "spectrum";
32
+ this.accentColor = options.accentColor ?? DEFAULT_ACCENT_COLOR;
33
+ this.shuffleEnabled = options.initialShuffle ?? false;
34
+ this.debugEnabled = options.debug ?? false;
35
+ this.durationFallbackEnabled = options.durationFallback ?? true;
36
+ this.context = new AudioContext();
37
+ this.sourceNode = this.context.createMediaElementSource(this.audio);
38
+ this.analyser = this.context.createAnalyser();
39
+ this.configureAnalyzer(options.analyzer);
40
+ this.sourceNode.connect(this.analyser);
41
+ this.analyser.connect(this.context.destination);
42
+ this.bindAudioEvents();
43
+ if (options.playlist?.length) {
44
+ this.setPlaylist(options.playlist, options.initialTrackIndex ?? 0);
45
+ }
46
+ else {
47
+ this.currentTrackIndex = options.initialTrackIndex ?? -1;
48
+ this.emitPlaylistChange();
49
+ }
50
+ this.events.emit("ready", undefined);
51
+ }
52
+ destroy() {
53
+ this.audio.pause();
54
+ this.events.removeAllListeners();
55
+ void this.context.close();
56
+ }
57
+ setAccentColor(color) {
58
+ this.accentColor = color;
59
+ }
60
+ getAccentColor() {
61
+ return this.accentColor;
62
+ }
63
+ applyAccentColor(target) {
64
+ target.style.setProperty("--smooth-player-accent", this.accentColor);
65
+ }
66
+ setShuffle(enabled) {
67
+ this.shuffleEnabled = enabled;
68
+ }
69
+ getShuffle() {
70
+ return this.shuffleEnabled;
71
+ }
72
+ setDebug(enabled) {
73
+ this.debugEnabled = enabled;
74
+ }
75
+ getDebug() {
76
+ return this.debugEnabled;
77
+ }
78
+ setPlaylist(entries, startIndex = 0) {
79
+ const resolved = this.resolvePlaylists(entries);
80
+ this.playlists = resolved;
81
+ if (!resolved.length) {
82
+ this.activePlaylistId = null;
83
+ this.currentTrackIndex = -1;
84
+ this.audio.removeAttribute("src");
85
+ this.audio.load();
86
+ this.resolvedDuration = Number.NaN;
87
+ this.resolvingDurationSrc = null;
88
+ this.events.emit("trackchange", { index: -1, track: null });
89
+ this.emitPlaylistChange();
90
+ this.emitDurationChange();
91
+ this.emitTimeUpdate();
92
+ return;
93
+ }
94
+ const firstResolved = resolved[0];
95
+ if (!firstResolved)
96
+ return;
97
+ const preferred = this.activePlaylistId && resolved.some((p) => p.id === this.activePlaylistId)
98
+ ? this.activePlaylistId
99
+ : firstResolved.id;
100
+ this.selectPlaylist(preferred ?? firstResolved.id, startIndex);
101
+ }
102
+ selectPlaylist(playlistId, startIndex = 0) {
103
+ const playlist = this.playlists.find((item) => item.id === playlistId);
104
+ if (!playlist) {
105
+ throw new Error(`Playlist ${playlistId} not found.`);
106
+ }
107
+ this.activePlaylistId = playlist.id;
108
+ this.emitPlaylistChange();
109
+ if (!playlist.tracks.length) {
110
+ this.currentTrackIndex = -1;
111
+ this.events.emit("trackchange", { index: -1, track: null });
112
+ this.emitDurationChange();
113
+ this.emitTimeUpdate();
114
+ return;
115
+ }
116
+ const safeIndex = Math.max(0, Math.min(startIndex, playlist.tracks.length - 1));
117
+ this.loadTrackByIndex(safeIndex);
118
+ }
119
+ getPlaylists() {
120
+ return this.playlists.map((playlist) => ({
121
+ id: playlist.id,
122
+ title: playlist.title,
123
+ count: playlist.tracks.length,
124
+ }));
125
+ }
126
+ getCurrentPlaylist() {
127
+ const playlist = this.getActivePlaylist();
128
+ if (!playlist)
129
+ return null;
130
+ return { id: playlist.id, title: playlist.title, tracks: [...playlist.tracks] };
131
+ }
132
+ getPlaylist() {
133
+ return [...this.getActiveTracks()];
134
+ }
135
+ getCurrentTrack() {
136
+ return this.getActiveTracks()[this.currentTrackIndex] ?? null;
137
+ }
138
+ getCurrentTrackIndex() {
139
+ return this.currentTrackIndex;
140
+ }
141
+ formatTime(value) {
142
+ if (!Number.isFinite(value) || value < 0)
143
+ return "--:--";
144
+ const m = Math.floor(value / 60).toString().padStart(2, "0");
145
+ const s = Math.floor(value % 60).toString().padStart(2, "0");
146
+ return `${m}:${s}`;
147
+ }
148
+ mountPlaylist(container, options = {}) {
149
+ const settings = {
150
+ listRole: options.listRole ?? "listbox",
151
+ itemClassName: options.itemClassName ?? "smooth-player__playlist-item",
152
+ titleClassName: options.titleClassName ?? "smooth-player__playlist-title",
153
+ artistClassName: options.artistClassName ?? "smooth-player__playlist-artist",
154
+ selectedAriaAttr: options.selectedAriaAttr ?? "aria-current",
155
+ getTitle: options.getTitle ?? ((track, index) => track.metadata?.title ?? `Track ${index + 1}`),
156
+ getArtist: options.getArtist ?? ((track) => track.metadata?.artist ?? "Unknown artist"),
157
+ };
158
+ const onSelect = options.onSelect;
159
+ const render = () => {
160
+ container.innerHTML = "";
161
+ container.setAttribute("role", settings.listRole);
162
+ const tracks = this.getActiveTracks();
163
+ tracks.forEach((track, index) => {
164
+ const item = document.createElement("li");
165
+ const entry = document.createElement("div");
166
+ entry.className = settings.itemClassName;
167
+ entry.setAttribute("role", "option");
168
+ entry.setAttribute("tabindex", "0");
169
+ entry.setAttribute(settings.selectedAriaAttr, String(index === this.currentTrackIndex));
170
+ const icon = document.createElement("span");
171
+ icon.className = "smooth-player__playlist-note";
172
+ icon.setAttribute("aria-hidden", "true");
173
+ const content = document.createElement("span");
174
+ content.className = "smooth-player__playlist-content";
175
+ const title = document.createElement("span");
176
+ title.className = settings.titleClassName;
177
+ title.textContent = settings.getTitle(track, index);
178
+ const artist = document.createElement("span");
179
+ artist.className = settings.artistClassName;
180
+ artist.textContent = settings.getArtist(track, index);
181
+ content.append(title, artist);
182
+ entry.append(icon, content);
183
+ const activate = async () => {
184
+ await this.play(index);
185
+ onSelect?.({ index, track });
186
+ };
187
+ entry.addEventListener("click", () => {
188
+ void activate();
189
+ });
190
+ entry.addEventListener("keydown", (event) => {
191
+ if (event.key !== "Enter" && event.key !== " ")
192
+ return;
193
+ event.preventDefault();
194
+ void activate();
195
+ });
196
+ item.append(entry);
197
+ container.append(item);
198
+ });
199
+ };
200
+ render();
201
+ const offTrackChange = this.on("trackchange", render);
202
+ const offPlaylistChange = this.on("playlistchange", render);
203
+ return () => {
204
+ offTrackChange();
205
+ offPlaylistChange();
206
+ container.innerHTML = "";
207
+ };
208
+ }
209
+ mountPlaylistSwitcher(container, options = {}) {
210
+ const itemClassName = options.itemClassName ?? "smooth-player__playlist-switcher-item";
211
+ const activeClassName = options.activeClassName ?? "is-active";
212
+ const onSelect = options.onSelect;
213
+ const doc = container.ownerDocument ?? document;
214
+ let isOpen = false;
215
+ const render = () => {
216
+ const playlists = this.getPlaylists();
217
+ container.innerHTML = "";
218
+ container.hidden = playlists.length <= 1;
219
+ if (container.hidden) {
220
+ isOpen = false;
221
+ return;
222
+ }
223
+ const currentPlaylist = this.getCurrentPlaylist();
224
+ const trigger = doc.createElement("button");
225
+ trigger.type = "button";
226
+ trigger.className = "smooth-player__playlist-switcher-trigger";
227
+ trigger.setAttribute("aria-haspopup", "listbox");
228
+ trigger.setAttribute("aria-expanded", String(isOpen));
229
+ trigger.textContent = currentPlaylist?.title ?? playlists[0]?.title ?? "Playlist";
230
+ const menu = doc.createElement("div");
231
+ menu.className = "smooth-player__playlist-switcher-menu";
232
+ menu.hidden = !isOpen;
233
+ menu.setAttribute("role", "listbox");
234
+ playlists.forEach((playlist) => {
235
+ const button = doc.createElement("button");
236
+ button.type = "button";
237
+ button.className = itemClassName;
238
+ button.textContent = `${playlist.title} (${playlist.count})`;
239
+ button.setAttribute("aria-pressed", String(playlist.id === this.activePlaylistId));
240
+ button.setAttribute("role", "option");
241
+ button.classList.toggle(activeClassName, playlist.id === this.activePlaylistId);
242
+ button.addEventListener("click", () => {
243
+ this.selectPlaylist(playlist.id, 0);
244
+ onSelect?.({ id: playlist.id, title: playlist.title });
245
+ isOpen = false;
246
+ render();
247
+ });
248
+ menu.append(button);
249
+ });
250
+ trigger.addEventListener("click", () => {
251
+ isOpen = !isOpen;
252
+ render();
253
+ });
254
+ container.classList.toggle("is-open", isOpen);
255
+ container.append(trigger, menu);
256
+ };
257
+ const onOutsidePointerDown = (event) => {
258
+ if (!isOpen)
259
+ return;
260
+ const target = event.target;
261
+ if (!(target instanceof Node))
262
+ return;
263
+ if (container.contains(target))
264
+ return;
265
+ isOpen = false;
266
+ render();
267
+ };
268
+ const onEscape = (event) => {
269
+ if (!isOpen)
270
+ return;
271
+ if (event.key !== "Escape")
272
+ return;
273
+ isOpen = false;
274
+ render();
275
+ };
276
+ doc.addEventListener("pointerdown", onOutsidePointerDown);
277
+ doc.addEventListener("keydown", onEscape);
278
+ render();
279
+ const offPlaylistChange = this.on("playlistchange", render);
280
+ return () => {
281
+ offPlaylistChange();
282
+ doc.removeEventListener("pointerdown", onOutsidePointerDown);
283
+ doc.removeEventListener("keydown", onEscape);
284
+ container.innerHTML = "";
285
+ };
286
+ }
287
+ mountPlaylistTitle(element, options = {}) {
288
+ const fallbackTitle = options.fallbackTitle ?? DEFAULT_PLAYLIST_TITLE;
289
+ const render = () => {
290
+ const playlist = this.getCurrentPlaylist();
291
+ element.textContent = playlist?.title ?? fallbackTitle;
292
+ };
293
+ render();
294
+ const off = this.on("playlistchange", render);
295
+ return () => off();
296
+ }
297
+ mountTrackInfo(titleElement, artistElement, options = {}) {
298
+ const unknownTitle = options.unknownTitle ?? "Unknown title";
299
+ const unknownArtist = options.unknownArtist ?? "Unknown artist";
300
+ const render = () => {
301
+ const track = this.getCurrentTrack();
302
+ titleElement.textContent = track?.metadata?.title ?? unknownTitle;
303
+ artistElement.textContent = track?.metadata?.artist ?? unknownArtist;
304
+ };
305
+ render();
306
+ const offTrackChange = this.on("trackchange", render);
307
+ return () => offTrackChange();
308
+ }
309
+ mountPlayButton(button, options = {}) {
310
+ const labelElement = options.labelElement ?? null;
311
+ const playLabel = options.playLabel ?? "Riproduci";
312
+ const pauseLabel = options.pauseLabel ?? "Pausa";
313
+ const render = () => {
314
+ const isPlaying = !this.audio.paused;
315
+ const label = isPlaying ? pauseLabel : playLabel;
316
+ button.setAttribute("aria-pressed", String(isPlaying));
317
+ button.setAttribute("aria-label", label);
318
+ if (labelElement) {
319
+ labelElement.textContent = label;
320
+ }
321
+ };
322
+ const onClick = async () => {
323
+ await this.toggle();
324
+ };
325
+ button.addEventListener("click", onClick);
326
+ render();
327
+ const offPlay = this.on("play", render);
328
+ const offPause = this.on("pause", render);
329
+ return () => {
330
+ button.removeEventListener("click", onClick);
331
+ offPlay();
332
+ offPause();
333
+ };
334
+ }
335
+ mountTransportControls(options) {
336
+ const { previousButton, nextButton } = options;
337
+ const onPrevious = () => this.previous();
338
+ const onNext = () => this.next();
339
+ previousButton.addEventListener("click", onPrevious);
340
+ nextButton.addEventListener("click", onNext);
341
+ return () => {
342
+ previousButton.removeEventListener("click", onPrevious);
343
+ nextButton.removeEventListener("click", onNext);
344
+ };
345
+ }
346
+ mountShuffleToggle(options) {
347
+ const { button, labelElement = null, activeClassName = "smooth-player__toggle-on", enabledLabel = "Disattiva shuffle", disabledLabel = "Attiva shuffle", initialEnabled = false, } = options;
348
+ const render = () => {
349
+ const enabled = this.getShuffle();
350
+ const label = enabled ? enabledLabel : disabledLabel;
351
+ button.setAttribute("aria-pressed", String(enabled));
352
+ button.setAttribute("aria-label", label);
353
+ button.classList.toggle(activeClassName, enabled);
354
+ if (labelElement) {
355
+ labelElement.textContent = label;
356
+ }
357
+ };
358
+ const toggle = () => {
359
+ this.setShuffle(!this.getShuffle());
360
+ render();
361
+ };
362
+ this.setShuffle(initialEnabled);
363
+ render();
364
+ button.addEventListener("click", toggle);
365
+ return () => {
366
+ button.removeEventListener("click", toggle);
367
+ };
368
+ }
369
+ mountPlaylistPanel(options) {
370
+ const { root, toggleButton, panel, closeButton = null, openClassName = "smooth-player--playlist-open", openLabel = "Apri playlist", closeLabel = "Chiudi playlist", } = options;
371
+ let isOpen = false;
372
+ const hasPlaylist = () => this.getPlaylists().length > 1 || this.getActiveTracks().length > 1;
373
+ const syncVisibility = () => {
374
+ toggleButton.hidden = !hasPlaylist();
375
+ };
376
+ const setOpen = (open) => {
377
+ if (!hasPlaylist()) {
378
+ isOpen = false;
379
+ root.classList.remove(openClassName);
380
+ panel.setAttribute("aria-hidden", "true");
381
+ toggleButton.setAttribute("aria-expanded", "false");
382
+ toggleButton.setAttribute("aria-label", openLabel);
383
+ return;
384
+ }
385
+ isOpen = open;
386
+ root.classList.toggle(openClassName, open);
387
+ panel.setAttribute("aria-hidden", String(!open));
388
+ toggleButton.setAttribute("aria-expanded", String(open));
389
+ toggleButton.setAttribute("aria-label", open ? closeLabel : openLabel);
390
+ };
391
+ const onToggle = () => setOpen(!isOpen);
392
+ const onClose = () => setOpen(false);
393
+ toggleButton.addEventListener("click", onToggle);
394
+ closeButton?.addEventListener("click", onClose);
395
+ const offPlaylistChange = this.on("playlistchange", () => {
396
+ syncVisibility();
397
+ if (!hasPlaylist())
398
+ setOpen(false);
399
+ });
400
+ syncVisibility();
401
+ setOpen(false);
402
+ return {
403
+ setOpen,
404
+ getOpen: () => isOpen,
405
+ destroy: () => {
406
+ offPlaylistChange();
407
+ toggleButton.removeEventListener("click", onToggle);
408
+ closeButton?.removeEventListener("click", onClose);
409
+ },
410
+ };
411
+ }
412
+ mountDebugPanel(options) {
413
+ const { enabled = this.debugEnabled, panel, sourceElement, currentTimeElement, durationElement, readyStateElement, networkStateElement, pausedElement, eventsElement, maxEvents = 18, } = options;
414
+ const events = [];
415
+ const log = (name) => {
416
+ if (!enabled)
417
+ return;
418
+ const line = `${new Date().toLocaleTimeString()} ${name} ct=${this.getCurrentTime().toFixed(2)} d=${this.getDuration().toFixed(2)}`;
419
+ events.unshift(line);
420
+ if (events.length > maxEvents)
421
+ events.pop();
422
+ eventsElement.textContent = events.join("\n");
423
+ };
424
+ const update = () => {
425
+ if (!enabled)
426
+ return;
427
+ sourceElement.textContent = this.audio.currentSrc || this.audio.src || "-";
428
+ currentTimeElement.textContent = Number.isFinite(this.getCurrentTime()) ? this.getCurrentTime().toFixed(3) : "NaN";
429
+ durationElement.textContent = Number.isFinite(this.getDuration()) ? this.getDuration().toFixed(3) : "NaN";
430
+ readyStateElement.textContent = String(this.audio.readyState);
431
+ networkStateElement.textContent = String(this.audio.networkState);
432
+ pausedElement.textContent = String(this.audio.paused);
433
+ };
434
+ panel.hidden = !enabled;
435
+ const offPlaylist = this.on("playlistchange", () => {
436
+ update();
437
+ log("player:playlistchange");
438
+ });
439
+ const offTrack = this.on("trackchange", () => {
440
+ update();
441
+ log("player:trackchange");
442
+ });
443
+ const offPlay = this.on("play", () => {
444
+ update();
445
+ log("player:play");
446
+ });
447
+ const offPause = this.on("pause", () => {
448
+ update();
449
+ log("player:pause");
450
+ });
451
+ const offTime = this.on("timeupdate", update);
452
+ const offDuration = this.on("durationchange", () => {
453
+ update();
454
+ log("player:durationchange");
455
+ });
456
+ update();
457
+ log("init");
458
+ return () => {
459
+ offPlaylist();
460
+ offTrack();
461
+ offPlay();
462
+ offPause();
463
+ offTime();
464
+ offDuration();
465
+ };
466
+ }
467
+ mountProgress(options) {
468
+ const { range, currentTimeElement = null, durationElement = null, progressRoot = null, ringElement = null, } = options;
469
+ let isScrubbing = false;
470
+ let isRingScrubbing = false;
471
+ const update = () => {
472
+ const duration = this.getDuration();
473
+ const currentTime = this.getCurrentTime();
474
+ const hasDuration = Number.isFinite(duration) && duration > 0;
475
+ const safeDuration = hasDuration ? duration : 0;
476
+ const safeCurrentTime = hasDuration
477
+ ? Math.max(0, Math.min(currentTime, safeDuration))
478
+ : Math.max(0, currentTime || 0);
479
+ const progressPercent = hasDuration ? (safeCurrentTime / safeDuration) * 100 : 0;
480
+ range.max = String(safeDuration);
481
+ range.value = String(safeCurrentTime);
482
+ range.style.setProperty("--smooth-player-progress", `${progressPercent}%`);
483
+ if (progressRoot) {
484
+ progressRoot.style.setProperty("--smooth-player-progress", `${progressPercent}%`);
485
+ progressRoot.style.setProperty("--smooth-player-progress-angle", `${progressPercent * 3.6}deg`);
486
+ }
487
+ if (currentTimeElement) {
488
+ currentTimeElement.textContent = this.formatTime(safeCurrentTime);
489
+ }
490
+ if (durationElement) {
491
+ durationElement.textContent = hasDuration ? this.formatTime(safeDuration) : "--:--";
492
+ }
493
+ };
494
+ const seekTo = (valueInSeconds) => {
495
+ const duration = this.getDuration();
496
+ if (!Number.isFinite(duration) || duration <= 0) {
497
+ update();
498
+ return;
499
+ }
500
+ const targetTime = Math.max(0, Math.min(valueInSeconds, duration));
501
+ this.seek(targetTime);
502
+ update();
503
+ };
504
+ const seekFromRingPointer = (clientX, clientY) => {
505
+ if (!ringElement)
506
+ return;
507
+ const duration = this.getDuration();
508
+ if (!Number.isFinite(duration) || duration <= 0)
509
+ return;
510
+ const rect = ringElement.getBoundingClientRect();
511
+ const centerX = rect.left + rect.width / 2;
512
+ const centerY = rect.top + rect.height / 2;
513
+ const dx = clientX - centerX;
514
+ const dy = clientY - centerY;
515
+ let angle = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
516
+ if (angle < 0)
517
+ angle += 360;
518
+ seekTo((angle / 360) * duration);
519
+ };
520
+ const onRangePointerDown = () => {
521
+ isScrubbing = true;
522
+ };
523
+ const onRangeInput = (event) => {
524
+ isScrubbing = true;
525
+ const target = event.target;
526
+ if (!(target instanceof HTMLInputElement))
527
+ return;
528
+ seekTo(Number(target.value));
529
+ };
530
+ const onRangeChange = (event) => {
531
+ const target = event.target;
532
+ if (!(target instanceof HTMLInputElement))
533
+ return;
534
+ seekTo(Number(target.value));
535
+ isScrubbing = false;
536
+ };
537
+ const onRingPointerDown = (event) => {
538
+ isRingScrubbing = true;
539
+ seekFromRingPointer(event.clientX, event.clientY);
540
+ };
541
+ const onPointerMove = (event) => {
542
+ if (!isRingScrubbing)
543
+ return;
544
+ seekFromRingPointer(event.clientX, event.clientY);
545
+ };
546
+ const onPointerUp = () => {
547
+ isScrubbing = false;
548
+ isRingScrubbing = false;
549
+ };
550
+ range.addEventListener("pointerdown", onRangePointerDown);
551
+ range.addEventListener("input", onRangeInput);
552
+ range.addEventListener("change", onRangeChange);
553
+ if (ringElement) {
554
+ ringElement.addEventListener("pointerdown", onRingPointerDown);
555
+ }
556
+ window.addEventListener("pointermove", onPointerMove);
557
+ window.addEventListener("pointerup", onPointerUp);
558
+ const offTimeUpdate = this.on("timeupdate", () => {
559
+ if (!isScrubbing)
560
+ update();
561
+ });
562
+ const offDurationChange = this.on("durationchange", () => {
563
+ if (!isScrubbing)
564
+ update();
565
+ });
566
+ update();
567
+ return () => {
568
+ range.removeEventListener("pointerdown", onRangePointerDown);
569
+ range.removeEventListener("input", onRangeInput);
570
+ range.removeEventListener("change", onRangeChange);
571
+ if (ringElement) {
572
+ ringElement.removeEventListener("pointerdown", onRingPointerDown);
573
+ }
574
+ window.removeEventListener("pointermove", onPointerMove);
575
+ window.removeEventListener("pointerup", onPointerUp);
576
+ offTimeUpdate();
577
+ offDurationChange();
578
+ };
579
+ }
580
+ async play(index) {
581
+ if (typeof index === "number") {
582
+ this.loadTrackByIndex(index);
583
+ }
584
+ if (!this.audio.src) {
585
+ throw new Error("No track loaded. Use playlist option, setPlaylist(), or loadTrack().");
586
+ }
587
+ if (this.context.state === "suspended") {
588
+ await this.context.resume();
589
+ }
590
+ await this.audio.play();
591
+ }
592
+ pause() {
593
+ this.audio.pause();
594
+ }
595
+ toggle() {
596
+ if (this.audio.paused) {
597
+ return this.play();
598
+ }
599
+ this.pause();
600
+ return Promise.resolve();
601
+ }
602
+ next() {
603
+ const tracks = this.getActiveTracks();
604
+ if (!tracks.length)
605
+ return;
606
+ if (this.shuffleEnabled && tracks.length > 1) {
607
+ const randomIndex = this.pickRandomTrackIndex(tracks.length);
608
+ this.loadTrackByIndex(randomIndex);
609
+ void this.play();
610
+ return;
611
+ }
612
+ const nextIndex = this.currentTrackIndex + 1;
613
+ if (nextIndex >= tracks.length) {
614
+ this.events.emit("ended", undefined);
615
+ return;
616
+ }
617
+ this.loadTrackByIndex(nextIndex);
618
+ void this.play();
619
+ }
620
+ previous() {
621
+ const tracks = this.getActiveTracks();
622
+ if (!tracks.length)
623
+ return;
624
+ const prevIndex = Math.max(this.currentTrackIndex - 1, 0);
625
+ this.loadTrackByIndex(prevIndex);
626
+ void this.play();
627
+ }
628
+ setLoop(loop) {
629
+ this.audio.loop = loop;
630
+ }
631
+ setVolume(volume) {
632
+ const safeVolume = this.clamp(volume);
633
+ this.audio.volume = safeVolume;
634
+ this.events.emit("volumechange", { volume: safeVolume });
635
+ }
636
+ seek(seconds) {
637
+ const duration = this.getDuration();
638
+ const safeSeconds = Number.isFinite(duration) && duration > 0
639
+ ? Math.max(0, Math.min(seconds, duration))
640
+ : Math.max(0, seconds);
641
+ this.audio.currentTime = safeSeconds;
642
+ this.emitTimeUpdate();
643
+ }
644
+ loadTrack(track) {
645
+ const playlist = this.getActivePlaylist();
646
+ if (!playlist) {
647
+ this.setPlaylist([track], 0);
648
+ return;
649
+ }
650
+ const existingIndex = playlist.tracks.findIndex((item) => item.id === track.id);
651
+ if (existingIndex >= 0) {
652
+ this.loadTrackByIndex(existingIndex);
653
+ return;
654
+ }
655
+ playlist.tracks.push(track);
656
+ this.loadTrackByIndex(playlist.tracks.length - 1);
657
+ this.emitPlaylistChange();
658
+ }
659
+ getState() {
660
+ const playlist = this.getActivePlaylist();
661
+ return {
662
+ currentTrackIndex: this.currentTrackIndex,
663
+ isPlaying: !this.audio.paused,
664
+ duration: this.getDuration(),
665
+ currentTime: this.audio.currentTime,
666
+ volume: this.audio.volume,
667
+ loop: this.audio.loop,
668
+ playlistId: playlist?.id ?? null,
669
+ playlistTitle: playlist?.title ?? DEFAULT_PLAYLIST_TITLE,
670
+ playlistCount: this.playlists.length,
671
+ visualizer: this.visualizerMode,
672
+ accentColor: this.accentColor,
673
+ shuffle: this.shuffleEnabled,
674
+ };
675
+ }
676
+ getAudioElement() {
677
+ return this.audio;
678
+ }
679
+ getCurrentTime() {
680
+ return this.audio.currentTime;
681
+ }
682
+ getDuration() {
683
+ const nativeDuration = this.audio.duration;
684
+ if (Number.isFinite(nativeDuration) && nativeDuration > 0) {
685
+ return nativeDuration;
686
+ }
687
+ if (Number.isFinite(this.resolvedDuration) && this.resolvedDuration > 0) {
688
+ return this.resolvedDuration;
689
+ }
690
+ return 0;
691
+ }
692
+ getSpectrumData() {
693
+ const data = new Uint8Array(this.analyser.frequencyBinCount);
694
+ if (this.visualizerMode !== "spectrum") {
695
+ return data;
696
+ }
697
+ this.analyser.getByteFrequencyData(data);
698
+ return data;
699
+ }
700
+ getWaveformData() {
701
+ const data = new Uint8Array(this.analyser.fftSize);
702
+ if (this.visualizerMode !== "waveform") {
703
+ return data;
704
+ }
705
+ this.analyser.getByteTimeDomainData(data);
706
+ return data;
707
+ }
708
+ setVisualizer(mode) {
709
+ this.visualizerMode = mode;
710
+ }
711
+ getVisualizer() {
712
+ return this.visualizerMode;
713
+ }
714
+ configureAnalyzer(options = {}) {
715
+ const config = { ...DEFAULT_ANALYZER, ...options };
716
+ this.analyser.fftSize = config.fftSize;
717
+ this.analyser.smoothingTimeConstant = config.smoothingTimeConstant;
718
+ this.analyser.minDecibels = config.minDecibels;
719
+ this.analyser.maxDecibels = config.maxDecibels;
720
+ }
721
+ getActivePlaylist() {
722
+ if (!this.activePlaylistId)
723
+ return null;
724
+ return this.playlists.find((playlist) => playlist.id === this.activePlaylistId) ?? null;
725
+ }
726
+ getActiveTracks() {
727
+ return this.getActivePlaylist()?.tracks ?? [];
728
+ }
729
+ loadTrackByIndex(index) {
730
+ const tracks = this.getActiveTracks();
731
+ const track = tracks[index];
732
+ if (!track) {
733
+ throw new Error(`Track index ${index} out of bounds.`);
734
+ }
735
+ this.currentTrackIndex = index;
736
+ this.audio.src = track.src;
737
+ this.audio.load();
738
+ this.resolvedDuration = Number.NaN;
739
+ this.resolvingDurationSrc = null;
740
+ this.events.emit("trackchange", { index, track });
741
+ this.emitDurationChange();
742
+ this.emitTimeUpdate();
743
+ this.syncDurationFromAudio();
744
+ }
745
+ emitPlaylistChange() {
746
+ const playlist = this.getActivePlaylist();
747
+ this.events.emit("playlistchange", {
748
+ id: playlist?.id ?? null,
749
+ title: playlist?.title ?? DEFAULT_PLAYLIST_TITLE,
750
+ index: this.currentTrackIndex,
751
+ });
752
+ }
753
+ bindAudioEvents() {
754
+ this.audio.addEventListener("play", () => this.events.emit("play", undefined));
755
+ this.audio.addEventListener("pause", () => this.events.emit("pause", undefined));
756
+ this.audio.addEventListener("loadedmetadata", () => this.syncDurationFromAudio());
757
+ this.audio.addEventListener("durationchange", () => this.syncDurationFromAudio());
758
+ this.audio.addEventListener("canplay", () => this.syncDurationFromAudio());
759
+ this.audio.addEventListener("loadeddata", () => this.syncDurationFromAudio());
760
+ this.audio.addEventListener("seeked", () => this.emitTimeUpdate());
761
+ this.audio.addEventListener("timeupdate", () => this.emitTimeUpdate());
762
+ this.audio.addEventListener("ended", () => {
763
+ if (this.getActiveTracks().length > 1 && !this.audio.loop) {
764
+ this.next();
765
+ return;
766
+ }
767
+ this.events.emit("ended", undefined);
768
+ });
769
+ this.audio.addEventListener("error", () => {
770
+ this.events.emit("error", {
771
+ error: new Error("Audio playback failed."),
772
+ });
773
+ });
774
+ }
775
+ clamp(value) {
776
+ return Math.min(1, Math.max(0, value));
777
+ }
778
+ pickRandomTrackIndex(length) {
779
+ if (length <= 1)
780
+ return 0;
781
+ let randomIndex = this.currentTrackIndex;
782
+ while (randomIndex === this.currentTrackIndex) {
783
+ randomIndex = Math.floor(Math.random() * length);
784
+ }
785
+ return randomIndex;
786
+ }
787
+ emitTimeUpdate() {
788
+ this.events.emit("timeupdate", {
789
+ currentTime: this.audio.currentTime,
790
+ duration: this.getDuration(),
791
+ });
792
+ }
793
+ emitDurationChange() {
794
+ this.events.emit("durationchange", { duration: this.getDuration() });
795
+ }
796
+ syncDurationFromAudio() {
797
+ const nativeDuration = this.audio.duration;
798
+ if (Number.isFinite(nativeDuration) && nativeDuration > 0) {
799
+ this.resolvedDuration = nativeDuration;
800
+ this.emitDurationChange();
801
+ this.emitTimeUpdate();
802
+ return;
803
+ }
804
+ if (!this.durationFallbackEnabled) {
805
+ this.emitDurationChange();
806
+ this.emitTimeUpdate();
807
+ return;
808
+ }
809
+ void this.resolveDurationFallback();
810
+ }
811
+ async resolveDurationFallback() {
812
+ const track = this.getCurrentTrack();
813
+ const src = track?.src ?? this.audio.currentSrc ?? this.audio.src;
814
+ if (!src)
815
+ return;
816
+ if (this.durationFallbackCache.has(src)) {
817
+ this.resolvedDuration = this.durationFallbackCache.get(src) ?? Number.NaN;
818
+ this.emitDurationChange();
819
+ this.emitTimeUpdate();
820
+ return;
821
+ }
822
+ if (this.resolvingDurationSrc === src) {
823
+ return;
824
+ }
825
+ this.resolvingDurationSrc = src;
826
+ try {
827
+ const response = await fetch(src);
828
+ if (!response.ok) {
829
+ return;
830
+ }
831
+ const arrayBuffer = await response.arrayBuffer();
832
+ const decoded = await this.context.decodeAudioData(arrayBuffer.slice(0));
833
+ const duration = decoded.duration;
834
+ if (!Number.isFinite(duration) || duration <= 0) {
835
+ return;
836
+ }
837
+ if (src !== (this.getCurrentTrack()?.src ?? this.audio.currentSrc ?? this.audio.src)) {
838
+ return;
839
+ }
840
+ this.durationFallbackCache.set(src, duration);
841
+ this.resolvedDuration = duration;
842
+ this.emitDurationChange();
843
+ this.emitTimeUpdate();
844
+ }
845
+ catch {
846
+ // ignore: keep unresolved duration when decode fails
847
+ }
848
+ finally {
849
+ if (this.resolvingDurationSrc === src) {
850
+ this.resolvingDurationSrc = null;
851
+ }
852
+ }
853
+ }
854
+ resolvePlaylists(entries) {
855
+ if (!entries.length)
856
+ return [];
857
+ const namedPlaylists = [];
858
+ const directRootTracks = this.collectDirectTracks(entries);
859
+ if (directRootTracks.length) {
860
+ namedPlaylists.push({
861
+ id: DEFAULT_PLAYLIST_ID,
862
+ title: DEFAULT_PLAYLIST_TITLE,
863
+ tracks: directRootTracks,
864
+ });
865
+ }
866
+ this.collectNestedPlaylists(entries, namedPlaylists);
867
+ if (!namedPlaylists.length) {
868
+ const fallbackTracks = this.flattenTracks(entries);
869
+ if (!fallbackTracks.length)
870
+ return [];
871
+ namedPlaylists.push({
872
+ id: DEFAULT_PLAYLIST_ID,
873
+ title: DEFAULT_PLAYLIST_TITLE,
874
+ tracks: fallbackTracks,
875
+ });
876
+ }
877
+ return this.dedupePlaylistIds(namedPlaylists);
878
+ }
879
+ collectDirectTracks(entries) {
880
+ const tracks = [];
881
+ for (const entry of entries) {
882
+ if (!this.isAudioPlaylist(entry)) {
883
+ tracks.push(entry);
884
+ }
885
+ }
886
+ return tracks;
887
+ }
888
+ flattenTracks(entries) {
889
+ const tracks = [];
890
+ for (const entry of entries) {
891
+ if (this.isAudioPlaylist(entry)) {
892
+ tracks.push(...this.flattenTracks(entry.tracks));
893
+ }
894
+ else {
895
+ tracks.push(entry);
896
+ }
897
+ }
898
+ return tracks;
899
+ }
900
+ collectNestedPlaylists(entries, target) {
901
+ for (const entry of entries) {
902
+ if (!this.isAudioPlaylist(entry))
903
+ continue;
904
+ const tracks = this.flattenTracks(entry.tracks);
905
+ if (tracks.length) {
906
+ target.push({ id: entry.id, title: entry.title, tracks });
907
+ }
908
+ this.collectNestedPlaylists(entry.tracks, target);
909
+ }
910
+ }
911
+ dedupePlaylistIds(playlists) {
912
+ const seen = new Set();
913
+ const deduped = [];
914
+ for (const playlist of playlists) {
915
+ let id = playlist.id || DEFAULT_PLAYLIST_ID;
916
+ if (seen.has(id)) {
917
+ let suffix = 2;
918
+ while (seen.has(`${id}-${suffix}`))
919
+ suffix += 1;
920
+ id = `${id}-${suffix}`;
921
+ }
922
+ seen.add(id);
923
+ deduped.push({ ...playlist, id });
924
+ }
925
+ return deduped;
926
+ }
927
+ isAudioPlaylist(entry) {
928
+ return "tracks" in entry && Array.isArray(entry.tracks);
929
+ }
930
+ }