smooth-player 1.0.1 → 2.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.
@@ -12,9 +12,40 @@ class CanvasVisualizer {
12
12
  cancelAnimationFrame(this.frameId);
13
13
  }
14
14
  }
15
+ function withAlpha(color, alpha) {
16
+ const safeAlpha = Math.max(0, Math.min(1, alpha));
17
+ const hex = color.trim();
18
+ if (/^#[\da-f]{3}$/i.test(hex)) {
19
+ const rChar = hex.charAt(1);
20
+ const gChar = hex.charAt(2);
21
+ const bChar = hex.charAt(3);
22
+ const r = parseInt(rChar + rChar, 16);
23
+ const g = parseInt(gChar + gChar, 16);
24
+ const b = parseInt(bChar + bChar, 16);
25
+ return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
26
+ }
27
+ if (/^#[\da-f]{6}$/i.test(hex)) {
28
+ const r = parseInt(hex.slice(1, 3), 16);
29
+ const g = parseInt(hex.slice(3, 5), 16);
30
+ const b = parseInt(hex.slice(5, 7), 16);
31
+ return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
32
+ }
33
+ const rgbMatch = hex.match(/^rgba?\(([^)]+)\)$/i);
34
+ if (rgbMatch) {
35
+ const parts = rgbMatch[1]?.split(",").map((part) => part.trim()) ?? [];
36
+ const r = Number(parts[0] ?? 255);
37
+ const g = Number(parts[1] ?? 255);
38
+ const b = Number(parts[2] ?? 255);
39
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
40
+ return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
41
+ }
42
+ }
43
+ return color;
44
+ }
15
45
  export class CanvasSpectrumVisualizer extends CanvasVisualizer {
16
46
  constructor(canvas, player, options = {}) {
17
47
  super(canvas, player);
48
+ this.ghostHeights = [];
18
49
  this.options = {
19
50
  width: options.width ?? canvas.width ?? 640,
20
51
  height: options.height ?? canvas.height ?? 160,
@@ -32,18 +63,50 @@ export class CanvasSpectrumVisualizer extends CanvasVisualizer {
32
63
  return;
33
64
  const data = this.player.getSpectrumData();
34
65
  const { width, height, background, color, barGap, barWidth } = this.options;
66
+ const style = this.player.getSpectrumStyle();
67
+ const isActive = !this.player.getAudioElement().paused;
35
68
  ctx.fillStyle = background;
36
69
  ctx.fillRect(0, 0, width, height);
70
+ if (!isActive) {
71
+ this.frameId = requestAnimationFrame(() => this.draw());
72
+ return;
73
+ }
37
74
  let x = 0;
38
75
  for (let i = 0; i < data.length && x < width; i += 1) {
39
76
  const value = (data[i] ?? 0) / 255;
40
77
  const barHeight = Math.max(2, value * height);
41
- ctx.fillStyle = color;
42
- ctx.fillRect(x, height - barHeight, barWidth, barHeight);
78
+ const baseY = style.inverted ? 0 : height;
79
+ const clampedHeight = barHeight;
80
+ if (style.dualLayer) {
81
+ const previous = this.ghostHeights[i] ?? clampedHeight;
82
+ const ghostHeight = previous * 0.88 + clampedHeight * 0.12;
83
+ this.ghostHeights[i] = ghostHeight;
84
+ ctx.globalAlpha = 0.34;
85
+ this.drawBar(ctx, x, baseY, barWidth, barGap, ghostHeight, color, style);
86
+ }
87
+ ctx.globalAlpha = 0.94;
88
+ this.drawBar(ctx, x, baseY, barWidth, barGap, clampedHeight, color, style);
43
89
  x += barWidth + barGap;
44
90
  }
91
+ ctx.globalAlpha = 1;
92
+ if (this.ghostHeights.length > data.length) {
93
+ this.ghostHeights = this.ghostHeights.slice(0, data.length);
94
+ }
45
95
  this.frameId = requestAnimationFrame(() => this.draw());
46
96
  }
97
+ drawBar(ctx, x, baseY, width, gap, height, color, style) {
98
+ const widthFactor = style.barWidth === "thin"
99
+ ? 1.9
100
+ : style.barWidth === "medium"
101
+ ? 5.7
102
+ : 17.1;
103
+ const maxDrawWidth = Math.max(1, width + gap - 1.8);
104
+ const drawWidth = Math.max(1, Math.min(width * widthFactor, maxDrawWidth));
105
+ const drawX = x - (drawWidth - width) / 2;
106
+ const topY = style.inverted ? baseY : baseY - height;
107
+ ctx.fillStyle = color;
108
+ ctx.fillRect(drawX, topY, drawWidth, height);
109
+ }
47
110
  }
48
111
  export class CanvasWaveformVisualizer extends CanvasVisualizer {
49
112
  constructor(canvas, player, options = {}) {
@@ -64,8 +127,13 @@ export class CanvasWaveformVisualizer extends CanvasVisualizer {
64
127
  return;
65
128
  const data = this.player.getWaveformData();
66
129
  const { width, height, background, color, lineWidth } = this.options;
130
+ const isActive = !this.player.getAudioElement().paused;
67
131
  ctx.fillStyle = background;
68
132
  ctx.fillRect(0, 0, width, height);
133
+ if (!isActive) {
134
+ this.frameId = requestAnimationFrame(() => this.draw());
135
+ return;
136
+ }
69
137
  ctx.lineWidth = lineWidth;
70
138
  ctx.strokeStyle = color;
71
139
  ctx.beginPath();
@@ -109,6 +177,7 @@ export class CanvasRadialVisualizer extends CanvasVisualizer {
109
177
  if (!ctx)
110
178
  return;
111
179
  const { width, height, background, color, mode, innerRadiusRatio, outerRadiusRatio, lineWidth, waveformAmplitude } = this.options;
180
+ const isActive = !this.player.getAudioElement().paused;
112
181
  const centerX = width / 2;
113
182
  const centerY = height / 2;
114
183
  const maxRadius = Math.min(width, height) / 2;
@@ -122,42 +191,80 @@ export class CanvasRadialVisualizer extends CanvasVisualizer {
122
191
  ctx.fillStyle = background;
123
192
  ctx.fillRect(0, 0, width, height);
124
193
  }
194
+ if (!isActive) {
195
+ this.frameId = requestAnimationFrame(() => this.draw());
196
+ return;
197
+ }
125
198
  if (mode === "spectrum") {
126
199
  const data = this.player.getSpectrumData();
127
- const sampleCount = Math.min(160, data.length);
200
+ const spectrumStyle = this.player.getSpectrumStyle();
201
+ const sampleCount = Math.min(spectrumStyle.barWidth === "thin"
202
+ ? 136
203
+ : spectrumStyle.barWidth === "medium"
204
+ ? 114
205
+ : 92, data.length);
128
206
  const step = (Math.PI * 2) / sampleCount;
129
- ctx.lineWidth = lineWidth;
207
+ const widthFactor = spectrumStyle.barWidth === "thin"
208
+ ? 2.05
209
+ : spectrumStyle.barWidth === "medium"
210
+ ? 6.15
211
+ : 18.45;
212
+ const ringCircumference = Math.PI * 2 * outerRadius;
213
+ const targetGap = spectrumStyle.barWidth === "thin"
214
+ ? 2.2
215
+ : spectrumStyle.barWidth === "medium"
216
+ ? 4.2
217
+ : 6.8;
218
+ const maxStrokeForGap = Math.max(1, ringCircumference / sampleCount - targetGap);
219
+ ctx.lineWidth = Math.min(lineWidth * widthFactor, maxStrokeForGap);
220
+ ctx.lineCap = "round";
130
221
  ctx.strokeStyle = color;
131
- ctx.shadowColor = color;
132
- ctx.shadowBlur = 8;
133
222
  for (let i = 0; i < sampleCount; i += 1) {
134
223
  const value = (data[i] ?? 0) / 255;
135
224
  const amplitude = Math.max(0.04, value);
136
225
  const angle = i * step - Math.PI / 2;
137
- const x0 = centerX + Math.cos(angle) * innerRadius;
138
- const y0 = centerY + Math.sin(angle) * innerRadius;
139
- const x1 = centerX + Math.cos(angle) * (innerRadius + amplitude * radialRange);
140
- const y1 = centerY + Math.sin(angle) * (innerRadius + amplitude * radialRange);
226
+ const outwardStart = innerRadius;
227
+ const outwardEnd = outwardStart + amplitude * radialRange;
228
+ const inwardStart = outerRadius;
229
+ const inwardEnd = Math.max(innerRadius, inwardStart - amplitude * radialRange);
230
+ const startRadius = spectrumStyle.inverted ? inwardStart : outwardStart;
231
+ const primaryEndRadius = spectrumStyle.inverted ? inwardEnd : outwardEnd;
232
+ const x0 = centerX + Math.cos(angle) * startRadius;
233
+ const y0 = centerY + Math.sin(angle) * startRadius;
234
+ const x1 = centerX + Math.cos(angle) * primaryEndRadius;
235
+ const y1 = centerY + Math.sin(angle) * primaryEndRadius;
141
236
  ctx.globalAlpha = 0.25 + amplitude * 0.75;
142
237
  ctx.beginPath();
143
238
  ctx.moveTo(x0, y0);
144
239
  ctx.lineTo(x1, y1);
145
240
  ctx.stroke();
241
+ if (spectrumStyle.dualLayer) {
242
+ const altStart = spectrumStyle.inverted ? outwardStart : inwardStart;
243
+ const altEnd = spectrumStyle.inverted ? outwardEnd : inwardEnd;
244
+ const inverseAngle = -i * step - Math.PI / 2;
245
+ const ax0 = centerX + Math.cos(inverseAngle) * altStart;
246
+ const ay0 = centerY + Math.sin(inverseAngle) * altStart;
247
+ const ax1 = centerX + Math.cos(inverseAngle) * altEnd;
248
+ const ay1 = centerY + Math.sin(inverseAngle) * altEnd;
249
+ ctx.globalAlpha = 0.18 + amplitude * 0.52;
250
+ ctx.beginPath();
251
+ ctx.moveTo(ax0, ay0);
252
+ ctx.lineTo(ax1, ay1);
253
+ ctx.stroke();
254
+ }
146
255
  }
147
- ctx.shadowBlur = 0;
148
256
  ctx.globalAlpha = 1;
149
257
  this.frameId = requestAnimationFrame(() => this.draw());
150
258
  return;
151
259
  }
152
260
  const data = this.player.getWaveformData();
261
+ const waveformStyle = this.player.getWaveformStyle();
153
262
  const sampleCount = Math.min(220, data.length);
154
263
  const step = (Math.PI * 2) / sampleCount;
155
264
  const amplitudeRange = radialRange * waveformAmplitude;
156
265
  const baseRadius = innerRadius + radialRange * 0.5;
157
- ctx.lineWidth = lineWidth;
266
+ ctx.lineWidth = waveformStyle.thickLine ? lineWidth * 2.25 : lineWidth;
158
267
  ctx.strokeStyle = color;
159
- ctx.shadowColor = color;
160
- ctx.shadowBlur = 10;
161
268
  ctx.globalAlpha = 0.95;
162
269
  ctx.beginPath();
163
270
  for (let i = 0; i < sampleCount; i += 1) {
@@ -175,7 +282,35 @@ export class CanvasRadialVisualizer extends CanvasVisualizer {
175
282
  }
176
283
  ctx.closePath();
177
284
  ctx.stroke();
178
- ctx.shadowBlur = 0;
285
+ if (waveformStyle.fill) {
286
+ const fillGradient = ctx.createRadialGradient(centerX, centerY, Math.max(1, innerRadius * 0.12), centerX, centerY, Math.max(innerRadius + radialRange * 0.65, 2));
287
+ fillGradient.addColorStop(0, withAlpha(color, 0));
288
+ fillGradient.addColorStop(0.58, withAlpha(color, 0.08));
289
+ fillGradient.addColorStop(1, withAlpha(color, 0.24));
290
+ ctx.globalAlpha = 1;
291
+ ctx.fillStyle = fillGradient;
292
+ ctx.fill();
293
+ }
294
+ if (waveformStyle.doubleLine) {
295
+ ctx.globalAlpha = 0.42;
296
+ ctx.lineWidth = Math.max(1, ctx.lineWidth * 0.72);
297
+ ctx.beginPath();
298
+ for (let i = 0; i < sampleCount; i += 1) {
299
+ const normalized = ((data[i] ?? 128) - 128) / 128;
300
+ const r = baseRadius + normalized * amplitudeRange * 0.72 + radialRange * 0.1;
301
+ const angle = i * step - Math.PI / 2;
302
+ const x = centerX + Math.cos(angle) * r;
303
+ const y = centerY + Math.sin(angle) * r;
304
+ if (i === 0) {
305
+ ctx.moveTo(x, y);
306
+ }
307
+ else {
308
+ ctx.lineTo(x, y);
309
+ }
310
+ }
311
+ ctx.closePath();
312
+ ctx.stroke();
313
+ }
179
314
  ctx.globalAlpha = 1;
180
315
  this.frameId = requestAnimationFrame(() => this.draw());
181
316
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SmoothPlayer = void 0;
4
4
  const events_js_1 = require("./events.js");
5
+ const strings_js_1 = require("./i18n/strings.js");
5
6
  const DEFAULT_ANALYZER = {
6
7
  fftSize: 2048,
7
8
  smoothingTimeConstant: 0.8,
@@ -9,8 +10,19 @@ const DEFAULT_ANALYZER = {
9
10
  maxDecibels: -10,
10
11
  };
11
12
  const DEFAULT_ACCENT_COLOR = "#0ed2a4";
12
- const DEFAULT_PLAYLIST_ID = "default";
13
- const DEFAULT_PLAYLIST_TITLE = "My playlist";
13
+ const DEFAULT_BACKGROUND_COLOR = "#0b1220";
14
+ const DEFAULT_PLAYLIST_ID = strings_js_1.strings.playlist.defaultId;
15
+ const DEFAULT_PLAYLIST_TITLE = strings_js_1.strings.playlist.defaultTitle;
16
+ const DEFAULT_SPECTRUM_STYLE = {
17
+ dualLayer: false,
18
+ inverted: false,
19
+ barWidth: "medium",
20
+ };
21
+ const DEFAULT_WAVEFORM_STYLE = {
22
+ doubleLine: false,
23
+ fill: false,
24
+ thickLine: false,
25
+ };
14
26
  class SmoothPlayer {
15
27
  constructor(options = {}) {
16
28
  this.events = new events_js_1.TypedEventEmitter();
@@ -32,7 +44,10 @@ class SmoothPlayer {
32
44
  this.audio.preload = this.audio.preload || "metadata";
33
45
  this.audio.volume = this.clamp(options.initialVolume ?? 1);
34
46
  this.visualizerMode = options.visualizer ?? "spectrum";
47
+ this.spectrumStyle = { ...DEFAULT_SPECTRUM_STYLE, ...options.spectrumStyle };
48
+ this.waveformStyle = { ...DEFAULT_WAVEFORM_STYLE, ...options.waveformStyle };
35
49
  this.accentColor = options.accentColor ?? DEFAULT_ACCENT_COLOR;
50
+ this.backgroundColor = options.backgroundColor ?? DEFAULT_BACKGROUND_COLOR;
36
51
  this.shuffleEnabled = options.initialShuffle ?? false;
37
52
  this.debugEnabled = options.debug ?? false;
38
53
  this.durationFallbackEnabled = options.durationFallback ?? true;
@@ -66,6 +81,30 @@ class SmoothPlayer {
66
81
  applyAccentColor(target) {
67
82
  target.style.setProperty("--smooth-player-accent", this.accentColor);
68
83
  }
84
+ setBackgroundColor(color) {
85
+ this.backgroundColor = color;
86
+ }
87
+ getBackgroundColor() {
88
+ return this.backgroundColor;
89
+ }
90
+ applyBackgroundColor(target) {
91
+ const normalized = this.backgroundColor.trim().toLowerCase();
92
+ if (normalized === DEFAULT_BACKGROUND_COLOR) {
93
+ target.style.removeProperty("--smooth-player-background");
94
+ target.style.removeProperty("--smooth-player-surface");
95
+ target.style.removeProperty("--smooth-player-panel");
96
+ target.style.removeProperty("--smooth-player-muted");
97
+ return;
98
+ }
99
+ target.style.setProperty("--smooth-player-background", this.backgroundColor);
100
+ target.style.setProperty("--smooth-player-surface", this.buildSurfaceGradient(this.backgroundColor));
101
+ target.style.setProperty("--smooth-player-panel", this.buildPanelGradient(this.backgroundColor));
102
+ target.style.setProperty("--smooth-player-muted", `color-mix(in srgb, ${this.backgroundColor} 30%, #d2deef 70%)`);
103
+ }
104
+ applyTheme(target) {
105
+ this.applyAccentColor(target);
106
+ this.applyBackgroundColor(target);
107
+ }
69
108
  setShuffle(enabled) {
70
109
  this.shuffleEnabled = enabled;
71
110
  }
@@ -155,8 +194,8 @@ class SmoothPlayer {
155
194
  titleClassName: options.titleClassName ?? "smooth-player__playlist-title",
156
195
  artistClassName: options.artistClassName ?? "smooth-player__playlist-artist",
157
196
  selectedAriaAttr: options.selectedAriaAttr ?? "aria-current",
158
- getTitle: options.getTitle ?? ((track, index) => track.metadata?.title ?? `Track ${index + 1}`),
159
- getArtist: options.getArtist ?? ((track) => track.metadata?.artist ?? "Unknown artist"),
197
+ getTitle: options.getTitle ?? ((track, index) => track.metadata?.title ?? `${strings_js_1.strings.track.unknownTitle} ${index + 1}`),
198
+ getArtist: options.getArtist ?? ((track) => track.metadata?.artist ?? strings_js_1.strings.track.unknownArtist),
160
199
  };
161
200
  const onSelect = options.onSelect;
162
201
  const render = () => {
@@ -229,7 +268,7 @@ class SmoothPlayer {
229
268
  trigger.className = "smooth-player__playlist-switcher-trigger";
230
269
  trigger.setAttribute("aria-haspopup", "listbox");
231
270
  trigger.setAttribute("aria-expanded", String(isOpen));
232
- trigger.textContent = currentPlaylist?.title ?? playlists[0]?.title ?? "Playlist";
271
+ trigger.textContent = currentPlaylist?.title ?? playlists[0]?.title ?? strings_js_1.strings.playlist.triggerLabel;
233
272
  const menu = doc.createElement("div");
234
273
  menu.className = "smooth-player__playlist-switcher-menu";
235
274
  menu.hidden = !isOpen;
@@ -298,8 +337,8 @@ class SmoothPlayer {
298
337
  return () => off();
299
338
  }
300
339
  mountTrackInfo(titleElement, artistElement, options = {}) {
301
- const unknownTitle = options.unknownTitle ?? "Unknown title";
302
- const unknownArtist = options.unknownArtist ?? "Unknown artist";
340
+ const unknownTitle = options.unknownTitle ?? strings_js_1.strings.track.unknownTitle;
341
+ const unknownArtist = options.unknownArtist ?? strings_js_1.strings.track.unknownArtist;
303
342
  const render = () => {
304
343
  const track = this.getCurrentTrack();
305
344
  titleElement.textContent = track?.metadata?.title ?? unknownTitle;
@@ -311,8 +350,8 @@ class SmoothPlayer {
311
350
  }
312
351
  mountPlayButton(button, options = {}) {
313
352
  const labelElement = options.labelElement ?? null;
314
- const playLabel = options.playLabel ?? "Riproduci";
315
- const pauseLabel = options.pauseLabel ?? "Pausa";
353
+ const playLabel = options.playLabel ?? strings_js_1.strings.playback.playLabel;
354
+ const pauseLabel = options.pauseLabel ?? strings_js_1.strings.playback.pauseLabel;
316
355
  const render = () => {
317
356
  const isPlaying = !this.audio.paused;
318
357
  const label = isPlaying ? pauseLabel : playLabel;
@@ -347,7 +386,7 @@ class SmoothPlayer {
347
386
  };
348
387
  }
349
388
  mountShuffleToggle(options) {
350
- const { button, labelElement = null, activeClassName = "smooth-player__toggle-on", enabledLabel = "Disattiva shuffle", disabledLabel = "Attiva shuffle", initialEnabled = false, } = options;
389
+ const { button, labelElement = null, activeClassName = "smooth-player__toggle-on", enabledLabel = strings_js_1.strings.shuffle.enabledLabel, disabledLabel = strings_js_1.strings.shuffle.disabledLabel, initialEnabled = false, } = options;
351
390
  const render = () => {
352
391
  const enabled = this.getShuffle();
353
392
  const label = enabled ? enabledLabel : disabledLabel;
@@ -370,24 +409,39 @@ class SmoothPlayer {
370
409
  };
371
410
  }
372
411
  mountPlaylistPanel(options) {
373
- const { root, toggleButton, panel, closeButton = null, openClassName = "smooth-player--playlist-open", openLabel = "Apri playlist", closeLabel = "Chiudi playlist", } = options;
412
+ const { root, toggleButton, panel, closeButton = null, openClassName = "smooth-player--playlist-open", openLabel = strings_js_1.strings.playlist.openLabel, closeLabel = strings_js_1.strings.playlist.closeLabel, } = options;
374
413
  let isOpen = false;
375
414
  const hasPlaylist = () => this.getPlaylists().length > 1 || this.getActiveTracks().length > 1;
376
415
  const syncVisibility = () => {
377
416
  toggleButton.hidden = !hasPlaylist();
378
417
  };
379
418
  const setOpen = (open) => {
419
+ const activeElement = (root.ownerDocument ?? document).activeElement;
420
+ const focusedInsidePanel = activeElement instanceof Node && panel.contains(activeElement);
380
421
  if (!hasPlaylist()) {
381
422
  isOpen = false;
423
+ if (focusedInsidePanel) {
424
+ toggleButton.focus();
425
+ }
382
426
  root.classList.remove(openClassName);
383
427
  panel.setAttribute("aria-hidden", "true");
428
+ panel.setAttribute("inert", "");
384
429
  toggleButton.setAttribute("aria-expanded", "false");
385
430
  toggleButton.setAttribute("aria-label", openLabel);
386
431
  return;
387
432
  }
388
433
  isOpen = open;
434
+ if (!open && focusedInsidePanel) {
435
+ toggleButton.focus();
436
+ }
389
437
  root.classList.toggle(openClassName, open);
390
438
  panel.setAttribute("aria-hidden", String(!open));
439
+ if (open) {
440
+ panel.removeAttribute("inert");
441
+ }
442
+ else {
443
+ panel.setAttribute("inert", "");
444
+ }
391
445
  toggleButton.setAttribute("aria-expanded", String(open));
392
446
  toggleButton.setAttribute("aria-label", open ? closeLabel : openLabel);
393
447
  };
@@ -580,12 +634,199 @@ class SmoothPlayer {
580
634
  offDurationChange();
581
635
  };
582
636
  }
637
+ mountAudioDrop(target, options = {}) {
638
+ const activeClassName = options.activeClassName ?? "is-drag-over";
639
+ const onDropCallback = options.onDrop;
640
+ let dragDepth = 0;
641
+ let activeObjectUrls = [];
642
+ const clearActiveState = () => {
643
+ dragDepth = 0;
644
+ target.classList.remove(activeClassName);
645
+ };
646
+ const revokeActiveUrls = () => {
647
+ for (const url of activeObjectUrls) {
648
+ URL.revokeObjectURL(url);
649
+ }
650
+ activeObjectUrls = [];
651
+ };
652
+ const isAudioFile = (file) => {
653
+ if (file.type.startsWith("audio/"))
654
+ return true;
655
+ return /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/i.test(file.name);
656
+ };
657
+ const isPlaylistFile = (file) => {
658
+ if (/\.m3u8?$/i.test(file.name))
659
+ return true;
660
+ return /(?:application|audio)\/(?:vnd\.apple\.mpegurl|x-mpegurl)/i.test(file.type);
661
+ };
662
+ const baseName = (value) => {
663
+ const noQuery = value.split("?")[0]?.split("#")[0] ?? value;
664
+ const normalized = noQuery.replace(/\\/g, "/");
665
+ const tail = normalized.split("/").pop() ?? normalized;
666
+ try {
667
+ return decodeURIComponent(tail);
668
+ }
669
+ catch {
670
+ return tail;
671
+ }
672
+ };
673
+ const guessTitle = (value) => baseName(value).replace(/\.[^/.]+$/, "") || strings_js_1.strings.track.unknownTitle;
674
+ const buildTrackFromAudioFile = (file, index) => {
675
+ const src = URL.createObjectURL(file);
676
+ activeObjectUrls.push(src);
677
+ const track = {
678
+ id: `dropped-${Date.now()}-${index}`,
679
+ src,
680
+ metadata: {
681
+ title: file.name.replace(/\.[^/.]+$/, ""),
682
+ artist: strings_js_1.strings.track.localFileArtist,
683
+ },
684
+ };
685
+ if (file.type) {
686
+ track.type = file.type;
687
+ }
688
+ return track;
689
+ };
690
+ const parseM3U = (content, localAudioFiles) => {
691
+ const localByName = new Map();
692
+ localAudioFiles.forEach((file) => {
693
+ localByName.set(file.name.toLowerCase(), file);
694
+ });
695
+ const tracks = [];
696
+ const lines = content.split(/\r?\n/);
697
+ let extInfTitle = null;
698
+ lines.forEach((raw, index) => {
699
+ const line = raw.trim().replace(/^\uFEFF/, "");
700
+ if (!line)
701
+ return;
702
+ if (line.startsWith("#EXTINF:")) {
703
+ const commaIndex = line.indexOf(",");
704
+ extInfTitle = commaIndex >= 0 ? line.slice(commaIndex + 1).trim() : null;
705
+ return;
706
+ }
707
+ if (line.startsWith("#"))
708
+ return;
709
+ const localFile = localByName.get(baseName(line).toLowerCase()) ?? null;
710
+ let src = "";
711
+ let type;
712
+ if (localFile) {
713
+ src = URL.createObjectURL(localFile);
714
+ activeObjectUrls.push(src);
715
+ type = localFile.type || undefined;
716
+ }
717
+ else {
718
+ try {
719
+ src = new URL(line, window.location.href).href;
720
+ }
721
+ catch {
722
+ src = "";
723
+ }
724
+ }
725
+ if (!src) {
726
+ extInfTitle = null;
727
+ return;
728
+ }
729
+ const track = {
730
+ id: `dropped-m3u-${Date.now()}-${index}`,
731
+ src,
732
+ metadata: {
733
+ title: extInfTitle || guessTitle(line),
734
+ artist: localFile ? strings_js_1.strings.track.localFileArtist : strings_js_1.strings.track.m3uArtist,
735
+ },
736
+ };
737
+ if (type) {
738
+ track.type = type;
739
+ }
740
+ tracks.push(track);
741
+ extInfTitle = null;
742
+ });
743
+ return tracks;
744
+ };
745
+ const hasFilePayload = (event) => {
746
+ const types = event.dataTransfer?.types;
747
+ if (!types)
748
+ return false;
749
+ return Array.from(types).includes("Files");
750
+ };
751
+ const onDragEnter = (event) => {
752
+ if (!hasFilePayload(event))
753
+ return;
754
+ event.preventDefault();
755
+ dragDepth += 1;
756
+ target.classList.add(activeClassName);
757
+ };
758
+ const onDragOver = (event) => {
759
+ if (!hasFilePayload(event))
760
+ return;
761
+ event.preventDefault();
762
+ if (event.dataTransfer) {
763
+ event.dataTransfer.dropEffect = "copy";
764
+ }
765
+ };
766
+ const onDragLeave = (event) => {
767
+ if (!hasFilePayload(event))
768
+ return;
769
+ event.preventDefault();
770
+ dragDepth = Math.max(0, dragDepth - 1);
771
+ if (dragDepth === 0) {
772
+ target.classList.remove(activeClassName);
773
+ }
774
+ };
775
+ const onDropEvent = async (event) => {
776
+ event.preventDefault();
777
+ clearActiveState();
778
+ const files = Array.from(event.dataTransfer?.files ?? []);
779
+ if (!files.length)
780
+ return;
781
+ const playlistFile = files.find(isPlaylistFile) ?? null;
782
+ const audioFiles = files.filter(isAudioFile);
783
+ let tracks = [];
784
+ let kind = "audio";
785
+ let sourceFile = null;
786
+ revokeActiveUrls();
787
+ if (playlistFile) {
788
+ kind = "playlist";
789
+ sourceFile = playlistFile;
790
+ const content = await playlistFile.text();
791
+ tracks = parseM3U(content, audioFiles);
792
+ }
793
+ else if (audioFiles.length) {
794
+ sourceFile = audioFiles[0] ?? null;
795
+ tracks = audioFiles.map((file, index) => buildTrackFromAudioFile(file, index));
796
+ }
797
+ const firstTrack = tracks[0];
798
+ if (!sourceFile || !firstTrack) {
799
+ this.events.emit("error", {
800
+ error: new Error(strings_js_1.strings.errors.noPlayableTracksDropped),
801
+ });
802
+ return;
803
+ }
804
+ this.setPlaylist(tracks, 0);
805
+ onDropCallback?.({ file: sourceFile, track: firstTrack, tracks, kind });
806
+ await this.play(0);
807
+ };
808
+ const onDrop = (event) => {
809
+ void onDropEvent(event);
810
+ };
811
+ target.addEventListener("dragenter", onDragEnter);
812
+ target.addEventListener("dragover", onDragOver);
813
+ target.addEventListener("dragleave", onDragLeave);
814
+ target.addEventListener("drop", onDrop);
815
+ return () => {
816
+ clearActiveState();
817
+ target.removeEventListener("dragenter", onDragEnter);
818
+ target.removeEventListener("dragover", onDragOver);
819
+ target.removeEventListener("dragleave", onDragLeave);
820
+ target.removeEventListener("drop", onDrop);
821
+ revokeActiveUrls();
822
+ };
823
+ }
583
824
  async play(index) {
584
825
  if (typeof index === "number") {
585
826
  this.loadTrackByIndex(index);
586
827
  }
587
828
  if (!this.audio.src) {
588
- throw new Error("No track loaded. Use playlist option, setPlaylist(), or loadTrack().");
829
+ throw new Error(strings_js_1.strings.errors.noTrackLoaded);
589
830
  }
590
831
  if (this.context.state === "suspended") {
591
832
  await this.context.resume();
@@ -672,7 +913,10 @@ class SmoothPlayer {
672
913
  playlistTitle: playlist?.title ?? DEFAULT_PLAYLIST_TITLE,
673
914
  playlistCount: this.playlists.length,
674
915
  visualizer: this.visualizerMode,
916
+ spectrumStyle: { ...this.spectrumStyle },
917
+ waveformStyle: { ...this.waveformStyle },
675
918
  accentColor: this.accentColor,
919
+ backgroundColor: this.backgroundColor,
676
920
  shuffle: this.shuffleEnabled,
677
921
  };
678
922
  }
@@ -714,6 +958,18 @@ class SmoothPlayer {
714
958
  getVisualizer() {
715
959
  return this.visualizerMode;
716
960
  }
961
+ setSpectrumStyle(options) {
962
+ this.spectrumStyle = { ...this.spectrumStyle, ...options };
963
+ }
964
+ getSpectrumStyle() {
965
+ return { ...this.spectrumStyle };
966
+ }
967
+ setWaveformStyle(options) {
968
+ this.waveformStyle = { ...this.waveformStyle, ...options };
969
+ }
970
+ getWaveformStyle() {
971
+ return { ...this.waveformStyle };
972
+ }
717
973
  configureAnalyzer(options = {}) {
718
974
  const config = { ...DEFAULT_ANALYZER, ...options };
719
975
  this.analyser.fftSize = config.fftSize;
@@ -721,6 +977,12 @@ class SmoothPlayer {
721
977
  this.analyser.minDecibels = config.minDecibels;
722
978
  this.analyser.maxDecibels = config.maxDecibels;
723
979
  }
980
+ buildSurfaceGradient(baseColor) {
981
+ return `linear-gradient(145deg, color-mix(in srgb, ${baseColor} 74%, #2a3f67 26%), color-mix(in srgb, ${baseColor} 82%, #15233e 18%) 56%, color-mix(in srgb, ${baseColor} 88%, #0a1324 12%))`;
982
+ }
983
+ buildPanelGradient(baseColor) {
984
+ return `linear-gradient(190deg, color-mix(in srgb, ${baseColor} 72%, #2a4069 28%) 0%, color-mix(in srgb, ${baseColor} 82%, #16243f 18%) 58%, color-mix(in srgb, ${baseColor} 88%, #0a1222 12%) 100%)`;
985
+ }
724
986
  getActivePlaylist() {
725
987
  if (!this.activePlaylistId)
726
988
  return null;
@@ -770,11 +1032,31 @@ class SmoothPlayer {
770
1032
  this.events.emit("ended", undefined);
771
1033
  });
772
1034
  this.audio.addEventListener("error", () => {
1035
+ const src = this.audio.currentSrc || this.audio.src || "";
1036
+ const mediaError = this.audio.error;
1037
+ const isCrossOrigin = this.isCrossOriginSource(src);
1038
+ const isPossiblyCors = isCrossOrigin && mediaError?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED;
1039
+ const message = isPossiblyCors
1040
+ ? strings_js_1.strings.errors.corsBlocked
1041
+ : strings_js_1.strings.errors.audioPlaybackFailed;
773
1042
  this.events.emit("error", {
774
- error: new Error("Audio playback failed."),
1043
+ error: new Error(message),
775
1044
  });
776
1045
  });
777
1046
  }
1047
+ isCrossOriginSource(src) {
1048
+ if (!src)
1049
+ return false;
1050
+ if (src.startsWith("blob:") || src.startsWith("data:") || src.startsWith("file:"))
1051
+ return false;
1052
+ try {
1053
+ const url = new URL(src, window.location.href);
1054
+ return url.origin !== window.location.origin;
1055
+ }
1056
+ catch {
1057
+ return false;
1058
+ }
1059
+ }
778
1060
  clamp(value) {
779
1061
  return Math.min(1, Math.max(0, value));
780
1062
  }