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/dist/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CanvasRadialVisualizer } from "./visualizers.js";
2
+ import { strings } from "./i18n/strings.js";
2
3
  function requiredElement(scope, selector) {
3
4
  const element = scope.querySelector(selector);
4
5
  if (!element) {
@@ -6,9 +7,103 @@ function requiredElement(scope, selector) {
6
7
  }
7
8
  return element;
8
9
  }
9
- export function mountStandardPlayerUI(player, root, options = {}) {
10
+ function renderPlayerMarkup(root, debugEnabled) {
11
+ root.classList.add("smooth-player");
12
+ root.innerHTML = `
13
+ <div class="smooth-player__main">
14
+ <div class="smooth-player__top">
15
+ <div class="smooth-player__top-title">${strings.playlist.defaultTitle}</div>
16
+ <button id="shuffle-toggle" type="button" aria-label="${strings.shuffle.disabledLabel}" aria-pressed="false" hidden>
17
+ <span class="smooth-player__icon-shuffle" aria-hidden="true"></span>
18
+ <span id="shuffle-text" class="smooth-player__sr-only">${strings.shuffle.disabledLabel}</span>
19
+ </button>
20
+ </div>
21
+
22
+ <div class="smooth-player__hero">
23
+ <div id="progress-ring" class="smooth-player__ring">
24
+ <div class="smooth-player__ring-inner">
25
+ <div class="smooth-player__cover">
26
+ <canvas id="radial-visualizer" class="smooth-player__cover-canvas" width="320" height="320"></canvas>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ <button id="play" class="smooth-player__hero-play" type="button" aria-label="${strings.playback.playLabel}" aria-pressed="false">
31
+ <span id="play-icon" class="smooth-player__icon-play" aria-hidden="true"></span>
32
+ <span id="play-text" class="smooth-player__sr-only">${strings.playback.playLabel}</span>
33
+ </button>
34
+ </div>
35
+
36
+ <div class="smooth-player__meta">
37
+ <strong id="title">-</strong>
38
+ <div id="artist" class="smooth-player__artist">-</div>
39
+ </div>
40
+
41
+ <div class="smooth-player__progress-wrap">
42
+ <input id="progress" class="smooth-player__progress" type="range" min="0" max="0" step="0.01" value="0" aria-label="Track position" />
43
+ <div class="smooth-player__progress-row">
44
+ <span id="time-current">00:00</span>
45
+ <span id="time-duration">00:00</span>
46
+ </div>
47
+ </div>
48
+
49
+ <div class="smooth-player__transport">
50
+ <button id="prev" class="secondary" type="button" aria-label="Previous track">
51
+ <img class="smooth-player__icon" src="/assets/icons/prev.svg" alt="" />
52
+ <span class="smooth-player__sr-only">Previous track</span>
53
+ </button>
54
+ <button id="playlist-toggle" class="secondary smooth-player__transport-playlist" type="button" aria-label="${strings.playlist.openLabel}" aria-expanded="false" hidden>
55
+ <img class="smooth-player__icon" src="/assets/icons/menu.svg" alt="" />
56
+ <span class="smooth-player__sr-only">${strings.playlist.openLabel}</span>
57
+ </button>
58
+ <button id="next" class="secondary" type="button" aria-label="Next track">
59
+ <img class="smooth-player__icon" src="/assets/icons/next.svg" alt="" />
60
+ <span class="smooth-player__sr-only">Next track</span>
61
+ </button>
62
+ </div>
63
+ </div>
64
+
65
+ <aside id="playlist-panel" class="smooth-player__playlist" aria-hidden="true">
66
+ <div class="smooth-player__playlist-head">
67
+ <h2>Playlist</h2>
68
+ <button id="playlist-close" class="smooth-player__playlist-close secondary" type="button" aria-label="${strings.playlist.closeLabel}">
69
+ <span aria-hidden="true">&times;</span>
70
+ <span class="smooth-player__sr-only">${strings.playlist.closeLabel}</span>
71
+ </button>
72
+ </div>
73
+ <ul id="playlist-list" class="smooth-player__playlist-list" role="listbox" aria-label="Track list"></ul>
74
+ </aside>
75
+ `;
76
+ if (!debugEnabled)
77
+ return;
78
+ const debugPanel = root.ownerDocument?.createElement("section") ?? document.createElement("section");
79
+ debugPanel.id = "debug-panel";
80
+ debugPanel.className = "smooth-player__debug";
81
+ debugPanel.setAttribute("aria-live", "polite");
82
+ debugPanel.hidden = true;
83
+ debugPanel.innerHTML = `
84
+ <h3>Audio Debug</h3>
85
+ <div class="smooth-player__debug-grid">
86
+ <div>src: <code id="dbg-src">-</code></div>
87
+ <div>currentTime: <code id="dbg-current-time">-</code></div>
88
+ <div>duration: <code id="dbg-duration">-</code></div>
89
+ <div>readyState: <code id="dbg-ready-state">-</code></div>
90
+ <div>networkState: <code id="dbg-network-state">-</code></div>
91
+ <div>paused: <code id="dbg-paused">-</code></div>
92
+ </div>
93
+ <pre id="dbg-events" class="smooth-player__debug-events"></pre>
94
+ `;
95
+ root.append(debugPanel);
96
+ }
97
+ export function mountPlayerUI(player, root, options = {}) {
10
98
  const doc = root.ownerDocument ?? document;
11
99
  const debugEnabled = options.debugEnabled ?? player.getDebug();
100
+ const enableAudioDrop = options.enableAudioDrop ?? true;
101
+ const enableErrorNotice = options.enableErrorNotice ?? true;
102
+ const showLogo = options.showLogo ?? true;
103
+ const persistUserPreferences = options.persistUserPreferences ?? true;
104
+ const preferencesCookieName = options.userPreferencesCookieName ?? "smooth_player_prefs";
105
+ const preferencesMaxAgeDays = Math.max(1, options.userPreferencesMaxAgeDays ?? 365);
106
+ renderPlayerMarkup(root, debugEnabled);
12
107
  const title = requiredElement(root, "#title");
13
108
  const artist = requiredElement(root, "#artist");
14
109
  const playlistTitle = requiredElement(root, ".smooth-player__top-title");
@@ -19,6 +114,7 @@ export function mountStandardPlayerUI(player, root, options = {}) {
19
114
  const playText = requiredElement(root, "#play-text");
20
115
  const prevButton = requiredElement(root, "#prev");
21
116
  const nextButton = requiredElement(root, "#next");
117
+ const transport = requiredElement(root, ".smooth-player__transport");
22
118
  const playlistToggle = requiredElement(root, "#playlist-toggle");
23
119
  const shuffleToggle = requiredElement(root, "#shuffle-toggle");
24
120
  const shuffleText = requiredElement(root, "#shuffle-text");
@@ -28,8 +124,267 @@ export function mountStandardPlayerUI(player, root, options = {}) {
28
124
  const playlistList = requiredElement(root, "#playlist-list");
29
125
  const radialCanvas = requiredElement(root, "#radial-visualizer");
30
126
  const progressRing = requiredElement(root, "#progress-ring");
127
+ const top = playlistTitle.parentElement;
31
128
  const unmounts = [];
32
129
  let radial = null;
130
+ let noticeTimer = null;
131
+ let brandLogo = null;
132
+ let visualizerPanelOpen = false;
133
+ const parsePreferencesCookie = () => {
134
+ if (!persistUserPreferences)
135
+ return null;
136
+ const cookie = doc.cookie
137
+ .split(";")
138
+ .map((part) => part.trim())
139
+ .find((part) => part.startsWith(`${preferencesCookieName}=`));
140
+ if (!cookie)
141
+ return null;
142
+ const encoded = cookie.slice(preferencesCookieName.length + 1);
143
+ try {
144
+ const parsed = JSON.parse(decodeURIComponent(encoded));
145
+ if (!parsed || typeof parsed !== "object")
146
+ return null;
147
+ return parsed;
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ };
153
+ const persistPreferences = () => {
154
+ if (!persistUserPreferences)
155
+ return;
156
+ const state = player.getState();
157
+ const payload = {
158
+ visualizer: state.visualizer,
159
+ spectrumStyle: state.spectrumStyle,
160
+ waveformStyle: state.waveformStyle,
161
+ shuffle: state.shuffle,
162
+ };
163
+ try {
164
+ const serialized = encodeURIComponent(JSON.stringify(payload));
165
+ const maxAge = Math.floor(preferencesMaxAgeDays * 24 * 60 * 60);
166
+ doc.cookie = `${preferencesCookieName}=${serialized}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
167
+ }
168
+ catch {
169
+ // Ignore cookie failures silently.
170
+ }
171
+ };
172
+ const applyStoredPreferences = () => {
173
+ const stored = parsePreferencesCookie();
174
+ if (!stored)
175
+ return;
176
+ if (stored.visualizer === "spectrum" || stored.visualizer === "waveform" || stored.visualizer === "none") {
177
+ player.setVisualizer(stored.visualizer);
178
+ }
179
+ if (stored.spectrumStyle && typeof stored.spectrumStyle === "object") {
180
+ const nextSpectrumStyle = {};
181
+ if (typeof stored.spectrumStyle.dualLayer === "boolean")
182
+ nextSpectrumStyle.dualLayer = stored.spectrumStyle.dualLayer;
183
+ if (typeof stored.spectrumStyle.inverted === "boolean")
184
+ nextSpectrumStyle.inverted = stored.spectrumStyle.inverted;
185
+ if (stored.spectrumStyle.barWidth === "thin" || stored.spectrumStyle.barWidth === "medium" || stored.spectrumStyle.barWidth === "large") {
186
+ nextSpectrumStyle.barWidth = stored.spectrumStyle.barWidth;
187
+ }
188
+ player.setSpectrumStyle(nextSpectrumStyle);
189
+ }
190
+ if (stored.waveformStyle && typeof stored.waveformStyle === "object") {
191
+ const nextWaveformStyle = {};
192
+ if (typeof stored.waveformStyle.doubleLine === "boolean")
193
+ nextWaveformStyle.doubleLine = stored.waveformStyle.doubleLine;
194
+ if (typeof stored.waveformStyle.fill === "boolean")
195
+ nextWaveformStyle.fill = stored.waveformStyle.fill;
196
+ if (typeof stored.waveformStyle.thickLine === "boolean")
197
+ nextWaveformStyle.thickLine = stored.waveformStyle.thickLine;
198
+ player.setWaveformStyle(nextWaveformStyle);
199
+ }
200
+ if (typeof stored.shuffle === "boolean") {
201
+ player.setShuffle(stored.shuffle);
202
+ }
203
+ };
204
+ applyStoredPreferences();
205
+ if (showLogo && top instanceof HTMLElement && !top.querySelector(".smooth-player__brand")) {
206
+ brandLogo = doc.createElement("span");
207
+ brandLogo.className = "smooth-player__brand";
208
+ brandLogo.setAttribute("aria-hidden", "true");
209
+ top.insertAdjacentElement("afterbegin", brandLogo);
210
+ }
211
+ const visualizerToggle = doc.createElement("button");
212
+ visualizerToggle.id = "visualizer-toggle";
213
+ visualizerToggle.type = "button";
214
+ visualizerToggle.className = "secondary";
215
+ const visualizerIcon = doc.createElement("span");
216
+ visualizerIcon.className = "smooth-player__icon-visualizer";
217
+ visualizerIcon.setAttribute("aria-hidden", "true");
218
+ const visualizerText = doc.createElement("span");
219
+ visualizerText.className = "smooth-player__sr-only";
220
+ visualizerToggle.append(visualizerIcon, visualizerText);
221
+ const stopButton = doc.createElement("button");
222
+ stopButton.id = "stop";
223
+ stopButton.type = "button";
224
+ stopButton.className = "secondary";
225
+ stopButton.setAttribute("aria-label", strings.playback.stopLabel);
226
+ const stopIcon = doc.createElement("span");
227
+ stopIcon.className = "smooth-player__icon-stop";
228
+ stopIcon.setAttribute("aria-hidden", "true");
229
+ const stopText = doc.createElement("span");
230
+ stopText.className = "smooth-player__sr-only";
231
+ stopText.textContent = strings.playback.stopLabel;
232
+ stopButton.append(stopIcon, stopText);
233
+ transport.insertBefore(stopButton, nextButton);
234
+ const playlistTop = doc.createElement("div");
235
+ playlistTop.className = "smooth-player__playlist-top";
236
+ top?.append(playlistTop);
237
+ playlistTop.append(playlistToggle, playlistTitle);
238
+ transport.prepend(shuffleToggle);
239
+ shuffleToggle.hidden = false;
240
+ transport.append(visualizerToggle);
241
+ const visualizerPanel = doc.createElement("div");
242
+ visualizerPanel.className = "smooth-player__visualizer-panel";
243
+ visualizerPanel.setAttribute("aria-hidden", "true");
244
+ visualizerPanel.innerHTML = `
245
+ <div class="smooth-player__playlist-head">
246
+ <h2>${strings.visualizer.panelTitle}</h2>
247
+ <button id="visualizer-close" class="smooth-player__playlist-close secondary" type="button" aria-label="${strings.visualizer.closeLabel}">
248
+ <span aria-hidden="true">&times;</span>
249
+ <span class="smooth-player__sr-only">${strings.visualizer.closeLabel}</span>
250
+ </button>
251
+ </div>
252
+ <div class="smooth-player__visualizer-mode-row" role="tablist" aria-label="${strings.visualizer.modeLabel}">
253
+ <button type="button" class="smooth-player__visualizer-mode" role="tab" data-mode="spectrum" aria-label="${strings.visualizer.modeSpectrum}" aria-controls="visualizer-spectrum-panel">
254
+ <svg class="smooth-player__visualizer-mode-icon" viewBox="0 0 24 24" aria-hidden="true">
255
+ <rect x="4" y="11" width="3" height="8" rx="1"></rect>
256
+ <rect x="10.5" y="6" width="3" height="13" rx="1"></rect>
257
+ <rect x="17" y="9" width="3" height="10" rx="1"></rect>
258
+ </svg>
259
+ <span class="smooth-player__visualizer-mode-text">${strings.visualizer.modeSpectrum}</span>
260
+ </button>
261
+ <button type="button" class="smooth-player__visualizer-mode" role="tab" data-mode="waveform" aria-label="${strings.visualizer.modeWaveform}" aria-controls="visualizer-waveform-panel">
262
+ <svg class="smooth-player__visualizer-mode-icon" viewBox="0 0 24 24" aria-hidden="true">
263
+ <path d="M2 12h3l2-4 3 8 3-8 2 4h7"></path>
264
+ </svg>
265
+ <span class="smooth-player__visualizer-mode-text">${strings.visualizer.modeWaveform}</span>
266
+ </button>
267
+ <button type="button" class="smooth-player__visualizer-mode" role="tab" data-mode="none" aria-label="${strings.visualizer.modeNone}" aria-controls="visualizer-off-panel">
268
+ <svg class="smooth-player__visualizer-mode-icon" viewBox="0 0 24 24" aria-hidden="true">
269
+ <circle cx="12" cy="12" r="7"></circle>
270
+ <path d="M7 17 17 7"></path>
271
+ </svg>
272
+ <span class="smooth-player__visualizer-mode-text">${strings.visualizer.modeNone}</span>
273
+ </button>
274
+ </div>
275
+ <div id="visualizer-spectrum-panel" class="smooth-player__visualizer-spectrum-options" role="tabpanel">
276
+ <div class="smooth-player__visualizer-spectrum-grid">
277
+ <div class="smooth-player__visualizer-spectrum-col">
278
+ <div class="smooth-player__visualizer-label">${strings.visualizer.effectLabel}</div>
279
+ <label class="smooth-player__visualizer-check"><input type="checkbox" id="visualizer-effect-dual" /> ${strings.visualizer.effectDualLayer}</label>
280
+ <label class="smooth-player__visualizer-check"><input type="checkbox" id="visualizer-effect-inverted" /> ${strings.visualizer.effectInverted}</label>
281
+ </div>
282
+ <div class="smooth-player__visualizer-spectrum-col">
283
+ <div class="smooth-player__visualizer-label">${strings.visualizer.barWidthLabel}</div>
284
+ <div class="smooth-player__visualizer-size-list" role="group" aria-label="${strings.visualizer.barWidthLabel}">
285
+ <button type="button" class="smooth-player__visualizer-size" data-size="thin" aria-label="${strings.visualizer.barWidthThin}">
286
+ <span class="smooth-player__size-bars smooth-player__size-bars--thin"><span></span><span></span><span></span></span>
287
+ </button>
288
+ <button type="button" class="smooth-player__visualizer-size" data-size="medium" aria-label="${strings.visualizer.barWidthMedium}">
289
+ <span class="smooth-player__size-bars smooth-player__size-bars--medium"><span></span><span></span><span></span></span>
290
+ </button>
291
+ <button type="button" class="smooth-player__visualizer-size" data-size="large" aria-label="${strings.visualizer.barWidthLarge}">
292
+ <span class="smooth-player__size-bars smooth-player__size-bars--large"><span></span><span></span><span></span></span>
293
+ </button>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ <div id="visualizer-waveform-panel" class="smooth-player__visualizer-waveform-options" role="tabpanel">
299
+ <div class="smooth-player__visualizer-label">${strings.visualizer.waveformEffectLabel}</div>
300
+ <label class="smooth-player__visualizer-check"><input type="checkbox" id="visualizer-wave-double" /> ${strings.visualizer.waveformEffectDoubleLine}</label>
301
+ <label class="smooth-player__visualizer-check"><input type="checkbox" id="visualizer-wave-fill" /> ${strings.visualizer.waveformEffectFill}</label>
302
+ <label class="smooth-player__visualizer-check"><input type="checkbox" id="visualizer-wave-thick" /> ${strings.visualizer.waveformEffectThickLine}</label>
303
+ </div>
304
+ <div id="visualizer-off-panel" class="smooth-player__visualizer-off-options" role="tabpanel" hidden></div>
305
+ `;
306
+ root.append(visualizerPanel);
307
+ const visualizerClose = visualizerPanel.querySelector("#visualizer-close");
308
+ const visualizerModeButtons = Array.from(visualizerPanel.querySelectorAll(".smooth-player__visualizer-mode"));
309
+ const spectrumOptions = visualizerPanel.querySelector(".smooth-player__visualizer-spectrum-options");
310
+ const waveformOptions = visualizerPanel.querySelector(".smooth-player__visualizer-waveform-options");
311
+ const offOptions = visualizerPanel.querySelector(".smooth-player__visualizer-off-options");
312
+ const effectDual = visualizerPanel.querySelector("#visualizer-effect-dual");
313
+ const effectInverted = visualizerPanel.querySelector("#visualizer-effect-inverted");
314
+ const spectrumBarWidthButtons = Array.from(visualizerPanel.querySelectorAll(".smooth-player__visualizer-size"));
315
+ const waveDouble = visualizerPanel.querySelector("#visualizer-wave-double");
316
+ const waveFill = visualizerPanel.querySelector("#visualizer-wave-fill");
317
+ const waveThick = visualizerPanel.querySelector("#visualizer-wave-thick");
318
+ const errorNotice = doc.createElement("div");
319
+ errorNotice.className = "smooth-player__notice";
320
+ errorNotice.setAttribute("role", "status");
321
+ errorNotice.setAttribute("aria-live", "assertive");
322
+ errorNotice.hidden = true;
323
+ root.append(errorNotice);
324
+ const showErrorNotice = (message) => {
325
+ if (!enableErrorNotice)
326
+ return;
327
+ errorNotice.textContent = message;
328
+ errorNotice.hidden = false;
329
+ errorNotice.classList.add("is-visible");
330
+ if (noticeTimer) {
331
+ window.clearTimeout(noticeTimer);
332
+ }
333
+ noticeTimer = window.setTimeout(() => {
334
+ errorNotice.classList.remove("is-visible");
335
+ errorNotice.hidden = true;
336
+ noticeTimer = null;
337
+ }, 6500);
338
+ };
339
+ const hideErrorNotice = () => {
340
+ if (noticeTimer) {
341
+ window.clearTimeout(noticeTimer);
342
+ noticeTimer = null;
343
+ }
344
+ errorNotice.classList.remove("is-visible");
345
+ errorNotice.hidden = true;
346
+ };
347
+ const clearTrackInfoState = () => {
348
+ title.classList.remove("is-track-exit", "is-track-enter");
349
+ artist.classList.remove("is-track-exit", "is-track-enter");
350
+ };
351
+ let trackInfoTimers = [];
352
+ const clearTrackInfoTimers = () => {
353
+ for (const timer of trackInfoTimers) {
354
+ window.clearTimeout(timer);
355
+ }
356
+ trackInfoTimers = [];
357
+ };
358
+ const renderTrackInfo = (animated) => {
359
+ const track = player.getCurrentTrack();
360
+ const nextTitle = track?.metadata?.title ?? strings.track.unknownTitle;
361
+ const nextArtist = track?.metadata?.artist ?? strings.track.unknownArtist;
362
+ clearTrackInfoTimers();
363
+ clearTrackInfoState();
364
+ if (!animated) {
365
+ title.textContent = nextTitle;
366
+ artist.textContent = nextArtist;
367
+ return;
368
+ }
369
+ title.classList.add("is-track-exit");
370
+ trackInfoTimers.push(window.setTimeout(() => {
371
+ artist.classList.add("is-track-exit");
372
+ }, 70));
373
+ trackInfoTimers.push(window.setTimeout(() => {
374
+ title.textContent = nextTitle;
375
+ title.classList.remove("is-track-exit");
376
+ title.classList.add("is-track-enter");
377
+ void title.offsetWidth;
378
+ title.classList.remove("is-track-enter");
379
+ }, 180));
380
+ trackInfoTimers.push(window.setTimeout(() => {
381
+ artist.textContent = nextArtist;
382
+ artist.classList.remove("is-track-exit");
383
+ artist.classList.add("is-track-enter");
384
+ void artist.offsetWidth;
385
+ artist.classList.remove("is-track-enter");
386
+ }, 260));
387
+ };
33
388
  const rebuildVisualizer = () => {
34
389
  radial?.stop();
35
390
  const mode = player.getVisualizer();
@@ -45,6 +400,156 @@ export function mountStandardPlayerUI(player, root, options = {}) {
45
400
  });
46
401
  radial.start();
47
402
  };
403
+ const modeLabel = (mode) => {
404
+ if (mode === "waveform")
405
+ return strings.visualizer.modeWaveform;
406
+ if (mode === "none")
407
+ return strings.visualizer.modeNone;
408
+ return strings.visualizer.modeSpectrum;
409
+ };
410
+ const renderVisualizerToggle = () => {
411
+ const mode = player.getVisualizer();
412
+ const label = `${strings.visualizer.toggleLabel}: ${modeLabel(mode)}`;
413
+ visualizerToggle.setAttribute("aria-label", label);
414
+ visualizerToggle.setAttribute("data-mode", mode);
415
+ visualizerToggle.setAttribute("aria-expanded", String(visualizerPanelOpen));
416
+ visualizerText.textContent = label;
417
+ };
418
+ const renderTopPlaylist = () => {
419
+ const playlist = player.getCurrentPlaylist();
420
+ playlistTitle.textContent = playlist?.title ?? strings.playlist.defaultTitle;
421
+ };
422
+ const renderVisualizerPanel = () => {
423
+ const mode = player.getVisualizer();
424
+ const style = player.getSpectrumStyle();
425
+ const wave = player.getWaveformStyle();
426
+ visualizerModeButtons.forEach((button) => {
427
+ const active = button.dataset.mode === mode;
428
+ button.classList.toggle("is-active", active);
429
+ button.setAttribute("aria-selected", String(active));
430
+ button.tabIndex = active ? 0 : -1;
431
+ });
432
+ effectDual.checked = style.dualLayer;
433
+ effectInverted.checked = style.inverted;
434
+ spectrumBarWidthButtons.forEach((button) => {
435
+ const size = button.dataset.size;
436
+ const active = size === style.barWidth;
437
+ button.classList.toggle("is-active", active);
438
+ button.setAttribute("aria-pressed", String(active));
439
+ });
440
+ waveDouble.checked = wave.doubleLine;
441
+ waveFill.checked = wave.fill;
442
+ waveThick.checked = wave.thickLine;
443
+ const spectrumEnabled = mode === "spectrum";
444
+ const waveformEnabled = mode === "waveform";
445
+ const offEnabled = mode === "none";
446
+ spectrumOptions.hidden = !spectrumEnabled;
447
+ waveformOptions.hidden = !waveformEnabled;
448
+ offOptions.hidden = !offEnabled;
449
+ effectDual.disabled = !spectrumEnabled;
450
+ effectInverted.disabled = !spectrumEnabled;
451
+ spectrumBarWidthButtons.forEach((button) => {
452
+ button.disabled = !spectrumEnabled;
453
+ });
454
+ waveDouble.disabled = !waveformEnabled;
455
+ waveFill.disabled = !waveformEnabled;
456
+ waveThick.disabled = !waveformEnabled;
457
+ };
458
+ const setVisualizerPanelOpen = (open) => {
459
+ const activeElement = (root.ownerDocument ?? document).activeElement;
460
+ const focusedInsidePanel = activeElement instanceof Node && visualizerPanel.contains(activeElement);
461
+ visualizerPanelOpen = open;
462
+ visualizerPanel.classList.toggle("is-open", open);
463
+ if (!open && focusedInsidePanel) {
464
+ visualizerToggle.focus();
465
+ }
466
+ visualizerPanel.setAttribute("aria-hidden", String(!open));
467
+ if (open) {
468
+ visualizerPanel.removeAttribute("inert");
469
+ }
470
+ else {
471
+ visualizerPanel.setAttribute("inert", "");
472
+ }
473
+ renderVisualizerToggle();
474
+ if (open) {
475
+ renderVisualizerPanel();
476
+ }
477
+ };
478
+ const onVisualizerModeClick = (event) => {
479
+ const target = event.currentTarget;
480
+ if (!(target instanceof HTMLButtonElement))
481
+ return;
482
+ const mode = target.dataset.mode;
483
+ if (mode !== "spectrum" && mode !== "waveform" && mode !== "none")
484
+ return;
485
+ player.setVisualizer(mode);
486
+ rebuildVisualizer();
487
+ renderVisualizerPanel();
488
+ renderVisualizerToggle();
489
+ persistPreferences();
490
+ };
491
+ const onSpectrumStyleChange = () => {
492
+ const nextStyle = {
493
+ dualLayer: effectDual.checked,
494
+ inverted: effectInverted.checked,
495
+ };
496
+ player.setSpectrumStyle(nextStyle);
497
+ rebuildVisualizer();
498
+ renderVisualizerPanel();
499
+ persistPreferences();
500
+ };
501
+ const onSpectrumBarWidthClick = (event) => {
502
+ const target = event.currentTarget;
503
+ if (!(target instanceof HTMLButtonElement))
504
+ return;
505
+ const size = target.dataset.size;
506
+ if (size !== "thin" && size !== "medium" && size !== "large")
507
+ return;
508
+ player.setSpectrumStyle({ barWidth: size });
509
+ rebuildVisualizer();
510
+ renderVisualizerPanel();
511
+ persistPreferences();
512
+ };
513
+ const onWaveformStyleChange = () => {
514
+ player.setWaveformStyle({
515
+ doubleLine: waveDouble.checked,
516
+ fill: waveFill.checked,
517
+ thickLine: waveThick.checked,
518
+ });
519
+ rebuildVisualizer();
520
+ renderVisualizerPanel();
521
+ persistPreferences();
522
+ };
523
+ const onVisualizerToggle = () => {
524
+ setVisualizerPanelOpen(!visualizerPanelOpen);
525
+ };
526
+ const onVisualizerClose = () => {
527
+ setVisualizerPanelOpen(false);
528
+ };
529
+ const onStop = () => {
530
+ player.pause();
531
+ player.seek(0);
532
+ };
533
+ const onShufflePersist = () => {
534
+ persistPreferences();
535
+ };
536
+ const onOutsidePointerDown = (event) => {
537
+ if (!visualizerPanelOpen)
538
+ return;
539
+ const target = event.target;
540
+ if (!(target instanceof Node))
541
+ return;
542
+ if (visualizerPanel.contains(target) || visualizerToggle.contains(target))
543
+ return;
544
+ setVisualizerPanelOpen(false);
545
+ };
546
+ const onEscapePanel = (event) => {
547
+ if (!visualizerPanelOpen)
548
+ return;
549
+ if (event.key !== "Escape")
550
+ return;
551
+ setVisualizerPanelOpen(false);
552
+ };
48
553
  const playlistPanelController = player.mountPlaylistPanel({
49
554
  root,
50
555
  toggleButton: playlistToggle,
@@ -56,21 +561,34 @@ export function mountStandardPlayerUI(player, root, options = {}) {
56
561
  unmounts.push(player.mountShuffleToggle({
57
562
  button: shuffleToggle,
58
563
  labelElement: shuffleText,
59
- initialEnabled: false,
564
+ initialEnabled: player.getShuffle(),
60
565
  }));
61
566
  unmounts.push(player.mountTransportControls({
62
567
  previousButton: prevButton,
63
568
  nextButton: nextButton,
64
569
  }));
570
+ visualizerToggle.addEventListener("click", onVisualizerToggle);
571
+ shuffleToggle.addEventListener("click", onShufflePersist);
572
+ visualizerClose.addEventListener("click", onVisualizerClose);
573
+ stopButton.addEventListener("click", onStop);
574
+ visualizerModeButtons.forEach((button) => button.addEventListener("click", onVisualizerModeClick));
575
+ effectDual.addEventListener("change", onSpectrumStyleChange);
576
+ effectInverted.addEventListener("change", onSpectrumStyleChange);
577
+ spectrumBarWidthButtons.forEach((button) => button.addEventListener("click", onSpectrumBarWidthClick));
578
+ waveDouble.addEventListener("change", onWaveformStyleChange);
579
+ waveFill.addEventListener("change", onWaveformStyleChange);
580
+ waveThick.addEventListener("change", onWaveformStyleChange);
581
+ doc.addEventListener("pointerdown", onOutsidePointerDown);
582
+ doc.addEventListener("keydown", onEscapePanel);
65
583
  if (debugEnabled) {
66
- const debugPanel = requiredElement(doc, "#debug-panel");
67
- const dbgSrc = requiredElement(doc, "#dbg-src");
68
- const dbgCurrentTime = requiredElement(doc, "#dbg-current-time");
69
- const dbgDuration = requiredElement(doc, "#dbg-duration");
70
- const dbgReadyState = requiredElement(doc, "#dbg-ready-state");
71
- const dbgNetworkState = requiredElement(doc, "#dbg-network-state");
72
- const dbgPaused = requiredElement(doc, "#dbg-paused");
73
- const dbgEvents = requiredElement(doc, "#dbg-events");
584
+ const debugPanel = requiredElement(root, "#debug-panel");
585
+ const dbgSrc = requiredElement(root, "#dbg-src");
586
+ const dbgCurrentTime = requiredElement(root, "#dbg-current-time");
587
+ const dbgDuration = requiredElement(root, "#dbg-duration");
588
+ const dbgReadyState = requiredElement(root, "#dbg-ready-state");
589
+ const dbgNetworkState = requiredElement(root, "#dbg-network-state");
590
+ const dbgPaused = requiredElement(root, "#dbg-paused");
591
+ const dbgEvents = requiredElement(root, "#dbg-events");
74
592
  unmounts.push(player.mountDebugPanel({
75
593
  enabled: true,
76
594
  panel: debugPanel,
@@ -83,12 +601,15 @@ export function mountStandardPlayerUI(player, root, options = {}) {
83
601
  eventsElement: dbgEvents,
84
602
  }));
85
603
  }
86
- player.applyAccentColor(root);
87
- unmounts.push(player.mountTrackInfo(title, artist));
604
+ player.applyTheme(root);
605
+ renderTrackInfo(false);
606
+ unmounts.push(player.on("trackchange", () => {
607
+ renderTrackInfo(true);
608
+ }));
88
609
  unmounts.push(player.mountPlayButton(playButton, {
89
610
  labelElement: playText,
90
- playLabel: "Riproduci",
91
- pauseLabel: "Pausa",
611
+ playLabel: strings.playback.playLabel,
612
+ pauseLabel: strings.playback.pauseLabel,
92
613
  }));
93
614
  unmounts.push(player.mountProgress({
94
615
  range: progress,
@@ -100,20 +621,52 @@ export function mountStandardPlayerUI(player, root, options = {}) {
100
621
  unmounts.push(player.mountPlaylist(playlistList, {
101
622
  onSelect: () => playlistPanelController.setOpen(false),
102
623
  }));
624
+ if (enableAudioDrop) {
625
+ unmounts.push(player.mountAudioDrop(root));
626
+ }
627
+ unmounts.push(player.on("error", ({ error }) => {
628
+ showErrorNotice(error.message);
629
+ }));
630
+ unmounts.push(player.on("play", hideErrorNotice));
631
+ unmounts.push(player.on("playlistchange", renderTopPlaylist));
103
632
  const switcher = doc.createElement("div");
104
633
  switcher.className = "smooth-player__playlist-switcher";
105
634
  playlistHead.insertAdjacentElement("afterend", switcher);
106
635
  unmounts.push(player.mountPlaylistSwitcher(switcher));
107
636
  rebuildVisualizer();
637
+ renderVisualizerToggle();
638
+ renderVisualizerPanel();
639
+ renderTopPlaylist();
108
640
  return {
109
641
  rebuildVisualizer,
110
642
  destroy: () => {
111
643
  radial?.stop();
112
644
  radial = null;
645
+ clearTrackInfoTimers();
646
+ clearTrackInfoState();
647
+ hideErrorNotice();
113
648
  for (const unmount of unmounts) {
114
649
  unmount();
115
650
  }
651
+ visualizerToggle.removeEventListener("click", onVisualizerToggle);
652
+ shuffleToggle.removeEventListener("click", onShufflePersist);
653
+ visualizerClose.removeEventListener("click", onVisualizerClose);
654
+ stopButton.removeEventListener("click", onStop);
655
+ visualizerModeButtons.forEach((button) => button.removeEventListener("click", onVisualizerModeClick));
656
+ effectDual.removeEventListener("change", onSpectrumStyleChange);
657
+ effectInverted.removeEventListener("change", onSpectrumStyleChange);
658
+ spectrumBarWidthButtons.forEach((button) => button.removeEventListener("click", onSpectrumBarWidthClick));
659
+ waveDouble.removeEventListener("change", onWaveformStyleChange);
660
+ waveFill.removeEventListener("change", onWaveformStyleChange);
661
+ waveThick.removeEventListener("change", onWaveformStyleChange);
662
+ doc.removeEventListener("pointerdown", onOutsidePointerDown);
663
+ doc.removeEventListener("keydown", onEscapePanel);
664
+ visualizerToggle.remove();
665
+ stopButton.remove();
666
+ visualizerPanel.remove();
667
+ errorNotice.remove();
116
668
  switcher.remove();
669
+ brandLogo?.remove();
117
670
  },
118
671
  };
119
672
  }
@@ -30,8 +30,10 @@ declare abstract class CanvasVisualizer {
30
30
  }
31
31
  export declare class CanvasSpectrumVisualizer extends CanvasVisualizer {
32
32
  private readonly options;
33
+ private ghostHeights;
33
34
  constructor(canvas: HTMLCanvasElement, player: SmoothPlayer, options?: SpectrumVisualizerOptions);
34
35
  protected draw(): void;
36
+ private drawBar;
35
37
  }
36
38
  export declare class CanvasWaveformVisualizer extends CanvasVisualizer {
37
39
  private readonly options;