tiny-essentials 1.28.4 → 1.28.6

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.
Files changed (66) hide show
  1. package/README.md +18 -1
  2. package/changelog/1/28/5.md +31 -0
  3. package/changelog/1/28/6.md +11 -0
  4. package/dist/v1/TinyAdvancedRaffle.min.js +1 -1
  5. package/dist/v1/TinyBasicsEs.min.js +1 -1
  6. package/dist/v1/TinyEssentials.min.js +1 -1
  7. package/dist/v1/TinyIframeEvents.min.js +1 -1
  8. package/dist/v1/TinyLocalStorage.min.js +1 -1
  9. package/dist/v1/TinyMediaPlayer.min.js +1 -1
  10. package/dist/v1/TinyNewWinEvents.min.js +1 -1
  11. package/dist/v1/TinyRadioFm.min.js +1 -1
  12. package/dist/v1/TinySmartScroller.min.js +1 -1
  13. package/dist/v1/TinyTimeout.min.js +1 -1
  14. package/dist/v1/TinyUploadClicker.min.js +1 -1
  15. package/dist/v1/basics/index.cjs +4 -0
  16. package/dist/v1/basics/index.d.mts +3 -2
  17. package/dist/v1/basics/index.mjs +3 -2
  18. package/dist/v1/basics/mediaContent.cjs +1 -0
  19. package/dist/v1/basics/mediaContent.d.mts +4 -0
  20. package/dist/v1/basics/mediaContent.mjs +1 -0
  21. package/dist/v1/basics/objChecker.cjs +16 -0
  22. package/dist/v1/basics/objChecker.d.mts +12 -0
  23. package/dist/v1/basics/objChecker.mjs +14 -0
  24. package/dist/v1/basics/objFilter.cjs +0 -2
  25. package/dist/v1/basics/objFilter.d.mts +0 -2
  26. package/dist/v1/basics/objFilter.mjs +1 -2
  27. package/dist/v1/basics/promiseUtils.cjs +101 -0
  28. package/dist/v1/basics/promiseUtils.d.mts +31 -0
  29. package/dist/v1/basics/promiseUtils.mjs +89 -0
  30. package/dist/v1/css/TinyLoadingScreen.min.css +1 -1
  31. package/dist/v1/index.cjs +4 -0
  32. package/dist/v1/index.d.mts +3 -2
  33. package/dist/v1/index.mjs +3 -2
  34. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.cjs +318 -0
  35. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.d.mts +143 -0
  36. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.mjs +283 -0
  37. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.cjs +315 -0
  38. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.d.mts +143 -0
  39. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.mjs +280 -0
  40. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.cjs +792 -0
  41. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.d.mts +300 -0
  42. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.mjs +697 -0
  43. package/dist/v1/libs/TinyMediaPlayer/Youtube.cjs +783 -0
  44. package/dist/v1/libs/TinyMediaPlayer/Youtube.d.mts +355 -0
  45. package/dist/v1/libs/TinyMediaPlayer/Youtube.mjs +694 -0
  46. package/dist/v1/libs/TinyMediaPlayer/docs/SoundCloudWidget.cjs +381 -0
  47. package/dist/v1/libs/TinyMediaPlayer/docs/SoundCloudWidget.d.mts +704 -0
  48. package/dist/v1/libs/TinyMediaPlayer/docs/SoundCloudWidget.mjs +343 -0
  49. package/dist/v1/libs/TinyMediaPlayer/docs/YouTubePlayer.cjs +546 -0
  50. package/dist/v1/libs/TinyMediaPlayer/docs/YouTubePlayer.d.mts +657 -0
  51. package/dist/v1/libs/TinyMediaPlayer/docs/YouTubePlayer.mjs +480 -0
  52. package/dist/v1/libs/TinyMediaPlayer/index.cjs +216 -0
  53. package/dist/v1/libs/TinyMediaPlayer/index.d.mts +162 -0
  54. package/dist/v1/libs/TinyMediaPlayer/index.mjs +190 -0
  55. package/dist/v1/libs/TinyMediaPlayer.cjs +627 -103
  56. package/dist/v1/libs/TinyMediaPlayer.d.mts +163 -65
  57. package/dist/v1/libs/TinyMediaPlayer.mjs +573 -93
  58. package/dist/v1/libs/TinyTimeout.cjs +4 -14
  59. package/dist/v1/libs/TinyTimeout.d.mts +1 -0
  60. package/dist/v1/libs/TinyTimeout.mjs +4 -13
  61. package/docs/v1/basics/mediaContent.md +1 -1
  62. package/docs/v1/basics/objChecker.md +13 -0
  63. package/docs/v1/basics/promiseUtils.md +32 -0
  64. package/docs/v1/libs/TinyMediaPlayer.md +268 -39
  65. package/docs/v1/libs/TinyTimeout.md +1 -14
  66. package/package.json +71 -10
@@ -0,0 +1,792 @@
1
+ 'use strict';
2
+
3
+ var events = require('events');
4
+ var index = require('./index.cjs');
5
+ var utils = require('../utils.cjs');
6
+ var promiseUtils = require('../../basics/promiseUtils.cjs');
7
+ var objChecker = require('../../basics/objChecker.cjs');
8
+
9
+ /**
10
+ * @typedef {import('../../basics/mediaContent.mjs').PictureDataType} PictureDataType
11
+ * @typedef {import('../../basics/mediaContent.mjs').MediaContent<PictureDataType>} MediaContent
12
+ * @typedef {import('./index.mjs').ContentTimeData} ContentTimeData
13
+ * @typedef {import('./index.mjs').ContentData} ContentData
14
+ *
15
+ * @typedef {import('./docs/SoundCloudWidget.mjs').SoundObject} SoundObject
16
+ * @typedef {import('./docs/SoundCloudWidget.mjs').MediaObject} MediaObject
17
+ * @typedef {import('./docs/SoundCloudWidget.mjs').TranscodingObject} TranscodingObject
18
+ * @typedef {import('./docs/SoundCloudWidget.mjs').PublisherObject} PublisherObject
19
+ * @typedef {import('./docs/SoundCloudWidget.mjs').UserBadgesObject} UserBadgesObject
20
+ * @typedef {import('./docs/SoundCloudWidget.mjs').CreatorSubscriptionObject} CreatorSubscriptionObject
21
+ * @typedef {import('./docs/SoundCloudWidget.mjs').VisualsObject} VisualsObject
22
+ * @typedef {import('./docs/SoundCloudWidget.mjs').VisualsObjectData} VisualsObjectData
23
+ * @typedef {import('./docs/SoundCloudWidget.mjs').UserObject} UserObject
24
+ */
25
+
26
+
27
+ const makeLoadSoundCloudApi = () => {
28
+ const { callback } = promiseUtils.createSingletonTask(async () => {
29
+ // Use existing API if already loaded
30
+ // @ts-ignore
31
+ if (typeof window !== 'undefined' && window.SC && window.SC.Widget) {
32
+ // @ts-ignore
33
+ SoundCloudMediaAdapter.Events = window.SC.Widget.Events;
34
+ return Promise.resolve(undefined);
35
+ }
36
+
37
+ return new Promise((resolve, reject) => {
38
+ let isError = false;
39
+ const tag = document.createElement('script');
40
+ tag.src = 'https://w.soundcloud.com/player/api.js';
41
+ tag.async = true;
42
+ tag.onerror = () => {
43
+ isError = true;
44
+ reject(new Error('Failed to load SoundCloud Widget API.'));
45
+ };
46
+ document.body.appendChild(tag);
47
+
48
+ // Check periodically if SC is ready
49
+ promiseUtils.waitForTrue(
50
+ // @ts-ignore
51
+ () => isError || (typeof window !== 'undefined' && window.SC && window.SC.Widget),
52
+ ).then(() => {
53
+ if (isError) return;
54
+ // @ts-ignore
55
+ SoundCloudMediaAdapter.Events = window.SC.Widget.Events;
56
+ resolve(undefined);
57
+ });
58
+ });
59
+ });
60
+ return callback;
61
+ };
62
+
63
+ /**
64
+ * Method to ensure the SoundCloud Widget API script is loaded.
65
+ */
66
+ const loadSoundCloudApi = makeLoadSoundCloudApi();
67
+
68
+ /**
69
+ * Returns a deep clone of the current YouTube player state values.
70
+ * This ensures the returned object is a copy and does not maintain a reference to the original state.
71
+ * @returns {Record<string, string>} A copy of the Events object.
72
+ */
73
+ const getEventValues = () => {
74
+ return structuredClone(SoundCloudMediaAdapter.Events);
75
+ };
76
+
77
+ /**
78
+ * Returns the SoundCloud Widget constructor from the global window object.
79
+ * @returns {typeof SoundCloudWidget} The SoundCloud Widget constructor.
80
+ * @throws {Error} If the SoundCloud API is not detected in the global scope.
81
+ */
82
+ const getSCWidget = () => {
83
+ // @ts-ignore
84
+ if (typeof window === 'undefined' || !window.SC || !window.SC.Widget)
85
+ throw new Error('SoundCloud API not available');
86
+ /** @type {typeof SoundCloudWidget} */
87
+ // @ts-ignore
88
+ const Widget = window.SC.Widget;
89
+ return Widget;
90
+ };
91
+
92
+ const checkDestroy = utils.createCheckDestroyed('SoundCloudMediaAdapter');
93
+
94
+ /**
95
+ * Implementation of BaseMediaAdapter for the SoundCloud Widget API.
96
+ * This adapter manages the lifecycle of a SoundCloud widget embedded in a container.
97
+ * @extends BaseMediaAdapter
98
+ */
99
+ class SoundCloudMediaAdapter extends index.BaseMediaAdapter {
100
+ /**
101
+ * Mapping SoundCloud API events for internal adapter events.
102
+ * @type {Record<string, string>}
103
+ * @private
104
+ */
105
+ static EVENT_MAPPING = {
106
+ onReady: 'onReady',
107
+ timeupdate: 'timeupdate',
108
+ play: 'play',
109
+ pause: 'pause',
110
+ ended: 'ended',
111
+ };
112
+
113
+ /**
114
+ * Static object containing event names to match SC.Widget.Events.
115
+ * @type {Record<string, string>}
116
+ */
117
+ static Events = {
118
+ LOAD_PROGRESS: 'LOAD_PROGRESS',
119
+ PLAY_PROGRESS: 'PLAY_PROGRESS',
120
+ PLAY: 'PLAY',
121
+ PAUSE: 'PAUSE',
122
+ FINISH: 'FINISH',
123
+ SEEK: 'SEEK',
124
+ READY: 'READY',
125
+ CLICK_DOWNLOAD: 'CLICK_DOWNLOAD',
126
+ CLICK_BUY: 'CLICK_BUY',
127
+ OPEN_SHARE_PANEL: 'OPEN_SHARE_PANEL',
128
+ ERROR: 'ERROR',
129
+ };
130
+
131
+ /**
132
+ * Safety lock: If true, multiple instances can share the same player/iframe via WeakMap.
133
+ * If false, only one instance can be bound to a specific container at a time.
134
+ * @type {boolean}
135
+ */
136
+ static allowInstanceSharing = false;
137
+
138
+ /**
139
+ * The default HTML element where the SoundCloud iframe will be injected.
140
+ * @type {HTMLIFrameElement|null}
141
+ */
142
+ static #defaultContainer = null;
143
+
144
+ /**
145
+ * Registry to reuse players and master emitters per container.
146
+ * @type {WeakMap<HTMLIFrameElement, {widget: SoundCloudWidget, masterEmitter: EventEmitter, refCount: number}>}
147
+ */
148
+ static #registry = new WeakMap();
149
+
150
+ /**
151
+ * Gets the globally configured default container for SoundCloud players.
152
+ * @returns {HTMLIFrameElement|null}
153
+ */
154
+ static get defaultContainer() {
155
+ return this.#defaultContainer;
156
+ }
157
+
158
+ /**
159
+ * Sets the globally configured default container for SoundCloud players.
160
+ * @param {HTMLIFrameElement|null} element - The element to be used as the default container.
161
+ * @throws {TypeError} If the provided element is not an instance of HTMLIFrameElement.
162
+ */
163
+ static set defaultContainer(element) {
164
+ if (element !== null && !(element instanceof HTMLIFrameElement)) {
165
+ throw new TypeError('The defaultContainer must be an instance of HTMLIFrameElement.');
166
+ }
167
+ this.#defaultContainer = element;
168
+ }
169
+
170
+ /**
171
+ * Creates a SoundCloud iframe element.
172
+ * This element can be used as the `defaultContainer` for the adapter.
173
+ *
174
+ * @param {IframeContainerOptions} options - Configuration for the iframe creation.
175
+ * @returns {HTMLIFrameElement} The created iframe element.
176
+ * @throws {TypeError} If the `videoId` is not a valid string.
177
+ */
178
+ static createIframeContainer(options) {
179
+ const {
180
+ videoId,
181
+ width = 640,
182
+ height = 360,
183
+ hidden = true,
184
+ autoPlay = false,
185
+ startTrack = null,
186
+ color = null,
187
+ buying = false,
188
+ sharing = true,
189
+ download = true,
190
+ showArtwork = true,
191
+ showPlayCount = true,
192
+ showUser = true,
193
+ singleActive = true,
194
+ } = options;
195
+
196
+ if (typeof videoId !== 'string' || videoId.trim() === '') {
197
+ throw new TypeError('The "videoId" property is required and must be a valid string.');
198
+ }
199
+
200
+ const iframe = document.createElement('iframe');
201
+
202
+ // SoundCloud requires the widget API to be enabled via the URL or by including the script
203
+ iframe.src = `https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/${videoId}${typeof startTrack === 'number' ? `&start_track=${startTrack}` : ''}&auto_play=${autoPlay ? 'true' : 'false'}${typeof color === 'string' ? `&color=${color}` : ''}&buying=${buying ? 'true' : 'false'}&sharing=${sharing ? 'true' : 'false'}&download=${download ? 'true' : 'false'}&show_artwork=${showArtwork ? 'true' : 'false'}&show_playcount=${showPlayCount ? 'true' : 'false'}&show_user=${showUser ? 'true' : 'false'}&single_active=${singleActive ? 'true' : 'false'}`;
204
+ iframe.scrolling = 'no';
205
+ iframe.frameBorder = 'no';
206
+ iframe.allow = 'autoplay';
207
+ iframe.style.width = `${width}px`;
208
+ iframe.style.height = `${height}px`;
209
+ iframe.setAttribute('frameborder', '0');
210
+ iframe.setAttribute('allow', 'autoplay; encrypted-media');
211
+
212
+ if (hidden) {
213
+ iframe.style.opacity = '0';
214
+ iframe.style.pointerEvents = 'none';
215
+ // Absolute positioning outside the viewport to prevent layout shifting
216
+ iframe.style.position = 'absolute';
217
+ iframe.style.top = '-9999px';
218
+ iframe.style.left = '-9999px';
219
+ document.body.appendChild(iframe);
220
+ }
221
+
222
+ return iframe;
223
+ }
224
+
225
+ /**
226
+ * Returns the thumbnail URL for the currently loaded track.
227
+ * @param {string} trackId - The SoundCloud track ID.
228
+ * @returns {string} The thumbnail URL.
229
+ * @throws {TypeError} If the trackId is not a valid string.
230
+ */
231
+ static getThumbnailUrl(trackId) {
232
+ if (typeof trackId !== 'string' || trackId.trim() === '') {
233
+ throw new TypeError('The "trackId" must be a non-empty string.');
234
+ }
235
+ return `https://i.soundcloud.com/${trackId}.jpg`;
236
+ }
237
+
238
+ get id() {
239
+ return 'soundcloudEmbedApi';
240
+ }
241
+
242
+ /** @type {SoundCloudWidget|null} */
243
+ #widget = null;
244
+
245
+ /** @type {HTMLIFrameElement} */
246
+ #container;
247
+
248
+ #isReady = false;
249
+
250
+ #loadedProgress = 0;
251
+
252
+ get loadedProgress() {
253
+ return this.#loadedProgress;
254
+ }
255
+
256
+ /** @type {Promise<void>} */
257
+ #apiLoadedPromise;
258
+
259
+ /** @type {number} */
260
+ #currentVolume = 1.0;
261
+
262
+ /** @type {EventEmitter|null} */
263
+ #masterEmitter = null;
264
+
265
+ /** @type {Array<{eventName: string, handler: HandlerFunc}>} */
266
+ #eventHandlers = [];
267
+
268
+ /** @type {boolean} */
269
+ #destroyed = false;
270
+
271
+ /** @type {string|null} */
272
+ #currentContentId = null;
273
+
274
+ get currentContentId() {
275
+ return this.#currentContentId;
276
+ }
277
+
278
+ /** @type {ReturnType<typeof setInterval>|null} */
279
+ #timeUpdateInterval = null;
280
+
281
+ /** @type {number} */
282
+ #cachedDuration = 0;
283
+
284
+ /** @type {number} */
285
+ #cachedPosition = 0;
286
+
287
+ /**
288
+ * Gets whether the adapter has been destroyed.
289
+ * @returns {boolean}
290
+ */
291
+ get destroyed() {
292
+ return this.#destroyed;
293
+ }
294
+
295
+ /**
296
+ * Initializes the SoundCloud Media Adapter.
297
+ * @throws {Error} If allowInstanceSharing is false and the container is already in use.
298
+ */
299
+ constructor() {
300
+ const container = SoundCloudMediaAdapter.#defaultContainer;
301
+ if (!(container instanceof HTMLIFrameElement)) {
302
+ throw new TypeError(
303
+ 'The SoundCloudMediaAdapter.defaultContainer must be an instance of HTMLIFrameElement.',
304
+ );
305
+ }
306
+
307
+ if (
308
+ !SoundCloudMediaAdapter.allowInstanceSharing &&
309
+ SoundCloudMediaAdapter.#registry.has(container)
310
+ ) {
311
+ throw new Error(
312
+ 'Security Lock: This container is already bound to another SoundCloudMediaAdapter instance.',
313
+ );
314
+ }
315
+
316
+ super();
317
+ this.#container = container;
318
+ this.#apiLoadedPromise = loadSoundCloudApi();
319
+ }
320
+
321
+ /**
322
+ * @returns {SoundCloudWidget} The current SoundCloud widget instance.
323
+ * @throws {Error} If the widget has not been initialized.
324
+ */
325
+ get widget() {
326
+ checkDestroy(this.#destroyed);
327
+ if (!this.#widget) {
328
+ throw new Error('SoundCloud widget instance not initialized.');
329
+ }
330
+ return this.#widget;
331
+ }
332
+
333
+ /**
334
+ * @param {number} value - The volume level from 0.0 to 1.0.
335
+ * @throws {RangeError} If the volume is not between 0.0 and 1.0.
336
+ */
337
+ set volume(value) {
338
+ checkDestroy(this.#destroyed);
339
+ if (typeof value !== 'number' || value < 0 || value > 1) {
340
+ throw new RangeError('Volume must be a number between 0.0 and 1.0.');
341
+ }
342
+ this.#currentVolume = value;
343
+ if (this.#widget && typeof this.#widget.setVolume === 'function') {
344
+ this.#widget.setVolume(value * 100);
345
+ }
346
+ this.emit('volumeChange', this.#currentVolume);
347
+ }
348
+
349
+ /**
350
+ * @returns {number} The current volume level from 0.0 to 1.0.
351
+ */
352
+ get volume() {
353
+ checkDestroy(this.#destroyed);
354
+ return this.#currentVolume;
355
+ }
356
+
357
+ /**
358
+ * Synchronously checks whether the SoundCloud media adapter is fully initialized and ready.
359
+ * @returns {boolean} True if the adapter is ready, false otherwise.
360
+ */
361
+ isReady() {
362
+ checkDestroy(this.#destroyed);
363
+ return this.#isReady;
364
+ }
365
+
366
+ /**
367
+ * Asynchronously waits until the SoundCloud media adapter is fully initialized and ready.
368
+ * @returns {Promise<void>} A promise that resolves once the adapter is ready.
369
+ */
370
+ async waitIsReady() {
371
+ checkDestroy(this.#destroyed);
372
+ return promiseUtils.waitForTrue(() => this.#isReady);
373
+ }
374
+
375
+ /**
376
+ * Retrieves a consolidated object containing all time-related metrics for the current media content.
377
+ * @returns {ContentTimeData} An object containing total, current, remaining time, and playback percentage.
378
+ */
379
+ getTimeData() {
380
+ const total = this.getTotalDuration();
381
+ const current = this.getCurrentTime();
382
+ const remaining = total > 0 ? total - current : 0;
383
+ const playbackPercentage = total > 0 ? (current / total) * 100 : 0;
384
+ return { total, current, remaining, playbackPercentage };
385
+ }
386
+
387
+ /**
388
+ * Determines if the content is a valid SoundCloud URL or ID.
389
+ * @param {MediaContent} content - The media content to evaluate.
390
+ * @returns {boolean} True if the content is a SoundCloud link/ID, false otherwise.
391
+ */
392
+ static canHandle(content) {
393
+ if (!objChecker.isValidObj(content)) {
394
+ throw new TypeError('Content must be a valid object.');
395
+ }
396
+ if (typeof content.url !== 'string') return false;
397
+ // Regex to match SoundCloud track IDs
398
+ const scRegex = /^https?:\/\/api.(soundcloud\.com|snd\.sc)\/tracks\/(.*)$/;
399
+ const result = content.url.match(scRegex);
400
+ return result && result[2] ? true : false;
401
+ }
402
+
403
+ /**
404
+ * Determines if the content is a valid SoundCloud URL or ID.
405
+ * @param {MediaContent} content - The media content to evaluate.
406
+ * @returns {boolean} True if the content is a SoundCloud link/ID, false otherwise.
407
+ */
408
+ canHandle(content) {
409
+ checkDestroy(this.#destroyed);
410
+ return SoundCloudMediaAdapter.canHandle(content);
411
+ }
412
+
413
+ /**
414
+ * Plays the media content. If the player is already initialized with a different video,
415
+ * it will load the new video automatically.
416
+ *
417
+ * Example: https://api.soundcloud.com/tracks/XXXXXXXXX (From SoundCloud Embed)
418
+ * @param {MediaContent} content - The media content to play.
419
+ * @returns {Promise<void>}
420
+ * @throws {TypeError} If the content data is invalid.
421
+ * @throws {Error} If the track fails to load within the timeout period.
422
+ */
423
+ async play(content) {
424
+ checkDestroy(this.#destroyed);
425
+ await this.#apiLoadedPromise;
426
+
427
+ if (!objChecker.isValidObj(content) || !content.url) {
428
+ throw new TypeError('Invalid media content provided to play().');
429
+ }
430
+
431
+ const songSource = typeof content.url === 'string' ? content.url : '';
432
+ const targetId = this.#extractId(songSource);
433
+
434
+ if (!this.#widget) {
435
+ await this.#initializeWidget(targetId);
436
+ }
437
+
438
+ // If the video is different, load the new one
439
+ if (this.#currentContentId !== targetId) {
440
+ this.#currentContentId = targetId;
441
+
442
+ await new Promise(async (resolve, reject) => {
443
+ let timeout = false;
444
+ // We set a time limit of 10 seconds to prevent await from locking the system
445
+ const timeoutId = setTimeout(() => {
446
+ timeout = true;
447
+ reject(new Error('SoundCloud track load timeout: The track took too long to load.'));
448
+ }, 10000);
449
+
450
+ // Command to load the new song
451
+ this.#widget?.load(`https://api.soundcloud.com/tracks/${targetId}`);
452
+ await promiseUtils.waitForTrue(() => true, 500);
453
+ /** @type {Partial<SoundObject>} */
454
+ let sound = {};
455
+
456
+ // We registered the listener for the READY event before calling the load
457
+ await promiseUtils.waitForTrue(() => {
458
+ this.#widget?.getCurrentSound((s) => {
459
+ sound = s;
460
+ });
461
+ return String(sound.id) === targetId;
462
+ }).then(() => {
463
+ if (timeout) return;
464
+ clearTimeout(timeoutId);
465
+ if (this.#widget && typeof this.#widget.setVolume === 'function') {
466
+ this.#widget.setVolume(this.#currentVolume * 100);
467
+ }
468
+ resolve(null);
469
+ });
470
+ });
471
+
472
+ // After Promise is resolved (READY event received), we begin play
473
+ this.#widget?.play();
474
+ } else {
475
+ // If it's the same song, we just play
476
+ this.#widget?.play();
477
+ }
478
+
479
+ return Promise.resolve();
480
+ }
481
+
482
+ /**
483
+ * Initializes the SoundCloud Widget.
484
+ * @param {string} trackId - The track ID.
485
+ * @returns {Promise<void>}
486
+ */
487
+ async #initializeWidget(trackId) {
488
+ checkDestroy(this.#destroyed);
489
+ const existing = SoundCloudMediaAdapter.#registry.get(this.#container);
490
+
491
+ if (existing) {
492
+ existing.refCount++; // Increment reference count for the shared player
493
+ this.#widget = existing.widget;
494
+ this.#masterEmitter = existing.masterEmitter;
495
+ this.#widget.getCurrentSound(
496
+ (audioData) => (this.#currentContentId = audioData ? (String(audioData.id) ?? null) : null),
497
+ );
498
+ this.#attachToMaster();
499
+ return;
500
+ }
501
+
502
+ // Create new master emitter for this specific container
503
+ const masterEmitter = new events.EventEmitter();
504
+ return new Promise((resolve) => {
505
+ let isReady = false;
506
+ const WidgetClass = getSCWidget();
507
+ this.#widget = new WidgetClass(this.#container);
508
+
509
+ const eventSync = (/** @type {any} */ event, /** @type {string} */ eventName) => {
510
+ if (!event) return;
511
+ if (typeof event.currentPosition === 'number') this.#cachedPosition = event.currentPosition;
512
+ if (typeof event.loadedProgress === 'number') this.#loadedProgress = event.loadedProgress;
513
+ if (typeof event.soundId === 'number') this.#currentContentId = String(event.soundId);
514
+ };
515
+
516
+ // Resolve the initialization promise when the player is ready
517
+ this.#widget.bind(SoundCloudMediaAdapter.Events.READY, (/** @type {any} */ event) => {
518
+ eventSync(event);
519
+ this.#masterEmitter?.emit('onReady');
520
+ if (isReady) return;
521
+ isReady = true;
522
+ this.#isReady = true;
523
+ resolve();
524
+ });
525
+
526
+ this.#widget.bind(SoundCloudMediaAdapter.Events.PLAY, (/** @type {any} */ event) => {
527
+ eventSync(event);
528
+ this.#masterEmitter?.emit('play');
529
+ });
530
+
531
+ this.#widget.bind(SoundCloudMediaAdapter.Events.PAUSE, (/** @type {any} */ event) => {
532
+ eventSync(event);
533
+ this.#masterEmitter?.emit('pause');
534
+ });
535
+
536
+ this.#widget.bind(SoundCloudMediaAdapter.Events.FINISH, (/** @type {any} */ event) => {
537
+ eventSync(event);
538
+ this.#masterEmitter?.emit('ended');
539
+ });
540
+
541
+ this.#widget.bind(SoundCloudMediaAdapter.Events.LOAD_PROGRESS, (/** @type {any} */ event) => {
542
+ eventSync(event);
543
+ });
544
+
545
+ this.#widget.bind(SoundCloudMediaAdapter.Events.PLAY_PROGRESS, (/** @type {any} */ event) => {
546
+ eventSync(event);
547
+ // SoundCloud uses callbacks for getters, so we update local cache
548
+ this.#widget?.getDuration((dur) => {
549
+ this.#cachedDuration = dur;
550
+ this.#masterEmitter?.emit('timeupdate', this.getTimeData());
551
+ });
552
+ });
553
+
554
+ this.#widget.bind(SoundCloudMediaAdapter.Events.SEEK, (/** @type {any} */ event) => {
555
+ eventSync(event);
556
+ this.#masterEmitter?.emit('seek', event.currentPosition);
557
+ });
558
+
559
+ for (const eventName of Object.values(SoundCloudMediaAdapter.Events)) {
560
+ this.#widget.bind(
561
+ SoundCloudMediaAdapter.Events[eventName],
562
+ (/** @type {any} */ ...args) => {
563
+ const result = masterEmitter.emit(eventName, ...args);
564
+ return result;
565
+ },
566
+ );
567
+ }
568
+
569
+ // Register this container and its master emitter with initial refCount
570
+ SoundCloudMediaAdapter.#registry.set(this.#container, {
571
+ widget: this.#widget,
572
+ masterEmitter: masterEmitter,
573
+ refCount: 1,
574
+ });
575
+
576
+ this.#masterEmitter = masterEmitter;
577
+ this.#attachToMaster();
578
+ });
579
+ }
580
+
581
+ /**
582
+ * Connects this instance to the master emitter of the shared player.
583
+ */
584
+ #attachToMaster() {
585
+ checkDestroy(this.#destroyed);
586
+ if (!this.#masterEmitter) return;
587
+
588
+ for (const eventName of Object.values({
589
+ ...SoundCloudMediaAdapter.EVENT_MAPPING,
590
+ ...SoundCloudMediaAdapter.Events,
591
+ })) {
592
+ /** @type {HandlerFunc} */
593
+ const handler = (...args) => this.emit(eventName, ...args);
594
+ this.#masterEmitter.on(eventName, handler);
595
+ this.#eventHandlers.push({ eventName, handler });
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Extracts the 11-character video ID from a URL or returns the string if it is already an ID.
601
+ * @param {string} url - The URL or ID.
602
+ * @returns {string} The extracted video ID.
603
+ */
604
+ #extractId(url) {
605
+ checkDestroy(this.#destroyed);
606
+ const regex =
607
+ /(?:soundcloud\.com\/[\w-]+\/([\w-]+))|(?:soundcloud\.com\/[\w-]+)\/([\w-]+)|([\w-]{11})/;
608
+ const match = url.match(regex);
609
+ return match ? match[1] || match[2] || match[3] : url;
610
+ }
611
+
612
+ /**
613
+ * Pauses the current playback.
614
+ * @returns {Promise<void>}
615
+ */
616
+ async pause() {
617
+ checkDestroy(this.#destroyed);
618
+ this.#widget?.pause();
619
+ }
620
+
621
+ /**
622
+ * Stops the playback completely.
623
+ * @returns {Promise<void>}
624
+ */
625
+ async stop() {
626
+ checkDestroy(this.#destroyed);
627
+ this.#widget?.pause();
628
+ this.#widget?.seekTo(0);
629
+ }
630
+
631
+ /**
632
+ * Seeks to a specific time in the media timeline.
633
+ * @param {number} timeMs - The target time in milliseconds.
634
+ * @returns {Promise<void>}
635
+ * @throws {TypeError} If timeMs is not a number.
636
+ */
637
+ async seek(timeMs) {
638
+ checkDestroy(this.#destroyed);
639
+ if (typeof timeMs !== 'number') throw new TypeError('Time must be a number.');
640
+ this.#widget?.seekTo(timeMs);
641
+ }
642
+
643
+ /**
644
+ * Retrieves the current playback time.
645
+ * @returns {number} The current time in milliseconds.
646
+ */
647
+ getCurrentTime() {
648
+ checkDestroy(this.#destroyed);
649
+ return this.#cachedPosition;
650
+ }
651
+
652
+ /**
653
+ * Gets the total duration of the YouTube video.
654
+ * @returns {number} The total duration in milliseconds.
655
+ */
656
+ getTotalDuration() {
657
+ checkDestroy(this.#destroyed);
658
+ return this.#cachedDuration;
659
+ }
660
+
661
+ /**
662
+ * Gets the remaining time until the YouTube video ends.
663
+ * @returns {number} The remaining time in milliseconds.
664
+ */
665
+ getRemainingTime() {
666
+ checkDestroy(this.#destroyed);
667
+ const total = this.getTotalDuration();
668
+ const current = this.getCurrentTime();
669
+ return total > 0 ? total - current : 0;
670
+ }
671
+
672
+ /**
673
+ * Gets the percentage of the YouTube video that has been played.
674
+ * @returns {number} The percentage from 0 to 100.
675
+ */
676
+ getPlaybackPercentage() {
677
+ checkDestroy(this.#destroyed);
678
+ const total = this.getTotalDuration();
679
+ const current = this.getCurrentTime();
680
+ return total > 0 ? (current / total) * 100 : 0;
681
+ }
682
+
683
+ /**
684
+ * Sets the playback volume.
685
+ * @param {number} volume - The volume level from 0.0 to 1.0.
686
+ * @returns {void}
687
+ * @throws {RangeError} If volume is outside [0.0, 1.0].
688
+ */
689
+ setVolume(volume) {
690
+ this.volume = volume;
691
+ }
692
+
693
+ /**
694
+ * Gets the playback volume.
695
+ * @returns {number}
696
+ * @throws {RangeError} If volume is outside [0.0, 1.0].
697
+ */
698
+ getVolume() {
699
+ return this.#currentVolume;
700
+ }
701
+
702
+ /**
703
+ * Retrieves the thumbnail URL for the currently loaded video with a specified quality.
704
+ * @returns {string|null} The thumbnail URL or null if no video is loaded.
705
+ */
706
+ getThumbnailUrl() {
707
+ checkDestroy(this.#destroyed);
708
+ return this.#currentContentId
709
+ ? SoundCloudMediaAdapter.getThumbnailUrl(this.#currentContentId)
710
+ : null;
711
+ }
712
+
713
+ /**
714
+ * Retrieves the metadata of the currently loaded SoundCloud music, returning a structured
715
+ * object containing details such as the audio ID, title, author, and duration.
716
+ * @returns {Promise<ContentData>}
717
+ */
718
+ async getContentData() {
719
+ return new Promise((resolve, reject) => {
720
+ if (!this.#widget) reject(new Error('No music data available from the SoundCloud player.'));
721
+ else
722
+ this.#widget.getCurrentSound((sound) => {
723
+ resolve({
724
+ id: String(sound.id ?? ''),
725
+ createdAt: String(sound.created_at ?? ''),
726
+ artistId: String(sound.user_id ?? ''),
727
+ artistName: String(sound.user?.full_name ?? ''),
728
+ description: String(sound.description ?? ''),
729
+ title: String(sound.label_name ?? ''),
730
+ duration: Number(sound.duration ?? ''),
731
+ avatar: String(sound.artwork_url ?? ''),
732
+ url: String(sound.uri ?? ''),
733
+ });
734
+ });
735
+ });
736
+ }
737
+
738
+ /**
739
+ * Cleans up the instance and disconnects from the shared player.
740
+ */
741
+ destroy() {
742
+ if (this.#destroyed) return;
743
+
744
+ if (this.#timeUpdateInterval) {
745
+ clearInterval(this.#timeUpdateInterval);
746
+ }
747
+
748
+ // 1. Detach from the master emitter
749
+ if (this.#masterEmitter && this.#eventHandlers.length > 0) {
750
+ for (const { eventName, handler } of this.#eventHandlers) {
751
+ this.#masterEmitter.off(eventName, handler);
752
+ }
753
+ this.#eventHandlers = [];
754
+ }
755
+
756
+ // 2. Handle Registry Cleanup (Reference Counting)
757
+ const existing = SoundCloudMediaAdapter.#registry.get(this.#container);
758
+ if (existing) {
759
+ existing.refCount--;
760
+ // If this was the last instance using this player/container, clean up the registry
761
+ if (existing.refCount <= 0) {
762
+ SoundCloudMediaAdapter.#registry.delete(this.#container);
763
+ }
764
+ }
765
+
766
+ this.#widget = null;
767
+ this.#masterEmitter = null;
768
+ this.#currentContentId = null;
769
+ this.#destroyed = true;
770
+ super.destroy();
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Asynchronously ensures the SoundCloud IFrame API is loaded and returns
776
+ * the SoundCloud Player constructor and the current player state mapping.
777
+ *
778
+ * @returns {Promise<{ Widget: typeof SoundCloudWidget }>}
779
+ * An object containing the Player constructor and the current player state mapping.
780
+ */
781
+ const getSC = async () => {
782
+ await loadSoundCloudApi();
783
+ return {
784
+ Widget: getSCWidget(),
785
+ };
786
+ };
787
+
788
+ exports.SoundCloudMediaAdapter = SoundCloudMediaAdapter;
789
+ exports.getEventValues = getEventValues;
790
+ exports.getSC = getSC;
791
+ exports.getSCWidget = getSCWidget;
792
+ exports.loadSoundCloudApi = loadSoundCloudApi;