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.
- package/README.md +116 -12
- package/assets/icons/logo-wave-preview.svg +28 -0
- package/assets/icons/logo.svg +8 -0
- package/assets/icons/stop.svg +3 -0
- package/assets/icons/upload.svg +5 -0
- package/assets/icons/visualizer.svg +7 -0
- package/dist/SmoothPlayer.d.ts +16 -1
- package/dist/SmoothPlayer.js +295 -13
- package/dist/i18n/en.generated.d.ts +50 -0
- package/dist/i18n/en.generated.js +51 -0
- package/dist/i18n/strings.d.ts +51 -0
- package/dist/i18n/strings.js +2 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/smooth-player.css +575 -154
- package/dist/types.d.ts +31 -0
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +567 -14
- package/dist/visualizers.d.ts +2 -0
- package/dist/visualizers.js +150 -15
- package/dist-cjs/SmoothPlayer.js +295 -13
- package/dist-cjs/i18n/en.generated.js +54 -0
- package/dist-cjs/i18n/strings.js +5 -0
- package/dist-cjs/index.js +4 -2
- package/dist-cjs/ui.js +568 -15
- package/dist-cjs/visualizers.js +150 -15
- package/package.json +15 -3
- package/styles/common/_base.scss +216 -95
- package/styles/themes/_nocturne.scss +408 -62
- package/styles/themes/_aurora.scss +0 -70
- package/styles/themes/_ocean.scss +0 -13
package/dist/visualizers.js
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const
|
|
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
|
-
|
|
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
|
}
|
package/dist-cjs/SmoothPlayer.js
CHANGED
|
@@ -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
|
|
13
|
-
const
|
|
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 ??
|
|
159
|
-
getArtist: options.getArtist ?? ((track) => track.metadata?.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 ??
|
|
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 ??
|
|
302
|
-
const unknownArtist = options.unknownArtist ??
|
|
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 ??
|
|
315
|
-
const pauseLabel = options.pauseLabel ??
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
}
|