tiny-essentials 1.28.4 → 1.28.5

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 (51) hide show
  1. package/changelog/1/28/5.md +31 -0
  2. package/dist/v1/TinyBasicsEs.min.js +1 -1
  3. package/dist/v1/TinyEssentials.min.js +1 -1
  4. package/dist/v1/TinyMediaPlayer.min.js +1 -1
  5. package/dist/v1/TinyTimeout.min.js +1 -1
  6. package/dist/v1/TinyUploadClicker.min.js +1 -1
  7. package/dist/v1/basics/index.cjs +4 -0
  8. package/dist/v1/basics/index.d.mts +3 -2
  9. package/dist/v1/basics/index.mjs +3 -2
  10. package/dist/v1/basics/mediaContent.cjs +1 -0
  11. package/dist/v1/basics/mediaContent.d.mts +4 -0
  12. package/dist/v1/basics/mediaContent.mjs +1 -0
  13. package/dist/v1/basics/objChecker.cjs +16 -0
  14. package/dist/v1/basics/objChecker.d.mts +12 -0
  15. package/dist/v1/basics/objChecker.mjs +14 -0
  16. package/dist/v1/basics/objFilter.cjs +0 -2
  17. package/dist/v1/basics/objFilter.d.mts +0 -2
  18. package/dist/v1/basics/objFilter.mjs +1 -2
  19. package/dist/v1/basics/promiseUtils.cjs +101 -0
  20. package/dist/v1/basics/promiseUtils.d.mts +31 -0
  21. package/dist/v1/basics/promiseUtils.mjs +89 -0
  22. package/dist/v1/index.cjs +4 -0
  23. package/dist/v1/index.d.mts +3 -2
  24. package/dist/v1/index.mjs +3 -2
  25. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.cjs +268 -0
  26. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.d.mts +118 -0
  27. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.mjs +237 -0
  28. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.cjs +265 -0
  29. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.d.mts +118 -0
  30. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.mjs +234 -0
  31. package/dist/v1/libs/TinyMediaPlayer/Youtube.cjs +671 -0
  32. package/dist/v1/libs/TinyMediaPlayer/Youtube.d.mts +259 -0
  33. package/dist/v1/libs/TinyMediaPlayer/Youtube.mjs +597 -0
  34. package/dist/v1/libs/TinyMediaPlayer/docs/YouTubePlayer.cjs +546 -0
  35. package/dist/v1/libs/TinyMediaPlayer/docs/YouTubePlayer.d.mts +657 -0
  36. package/dist/v1/libs/TinyMediaPlayer/docs/YouTubePlayer.mjs +480 -0
  37. package/dist/v1/libs/TinyMediaPlayer/index.cjs +165 -0
  38. package/dist/v1/libs/TinyMediaPlayer/index.d.mts +102 -0
  39. package/dist/v1/libs/TinyMediaPlayer/index.mjs +144 -0
  40. package/dist/v1/libs/TinyMediaPlayer.cjs +589 -103
  41. package/dist/v1/libs/TinyMediaPlayer.d.mts +150 -65
  42. package/dist/v1/libs/TinyMediaPlayer.mjs +535 -93
  43. package/dist/v1/libs/TinyTimeout.cjs +4 -14
  44. package/dist/v1/libs/TinyTimeout.d.mts +1 -0
  45. package/dist/v1/libs/TinyTimeout.mjs +4 -13
  46. package/docs/v1/basics/mediaContent.md +1 -1
  47. package/docs/v1/basics/objChecker.md +13 -0
  48. package/docs/v1/basics/promiseUtils.md +32 -0
  49. package/docs/v1/libs/TinyMediaPlayer.md +239 -32
  50. package/docs/v1/libs/TinyTimeout.md +1 -14
  51. package/package.json +21 -1
@@ -1,6 +1,8 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { getMediaContentBase, getMediaContentMetadata, parseMediaMetadata, valMediaContentMetadata, } from '../basics/mediaContent.mjs';
3
3
  import { createCheckDestroyed } from './utils.mjs';
4
+ import { BaseMediaAdapter } from './TinyMediaPlayer/index.mjs';
5
+ import { isValidObj } from '../basics/objChecker.mjs';
4
6
  const checkDestroy = createCheckDestroyed('TinyMediaPlayer');
5
7
  /**
6
8
  * @typedef {import('../basics/mediaContent.mjs').PictureDataType} PictureDataType
@@ -11,82 +13,21 @@ const checkDestroy = createCheckDestroyed('TinyMediaPlayer');
11
13
  * @typedef {import('../basics/mediaContent.mjs').LoadingMediaProgress} LoadingMediaProgress
12
14
  * @typedef {import('../basics/mediaContent.mjs').MediaLoadingErrorData} MediaLoadingErrorData
13
15
  * @typedef {import('../basics/mediaContent.mjs').UnknownArtistGetter} UnknownArtistGetter
16
+ * @typedef {import('./TinyMediaPlayer/index.mjs').ContentTimeData} ContentTimeData
14
17
  */
15
18
  /**
16
19
  * The loop configuration modes.
17
20
  * @typedef {'NONE' | 'TRACK' | 'PLAYLIST'} LoopModeType
18
21
  */
19
22
  /**
20
- * Configuration options for initializing the UniversalMediaPlayer.
23
+ * Configuration options for initializing the TinyMediaPlayer.
21
24
  * @typedef {Object} TinyMediaPlayerOptions
22
25
  * @property {boolean} [persistVolume=false] - Whether to automatically save the volume in localStorage.
23
- * @property {string} [volumeStorageKey='universal_media_player_volume'] - The specific key name used for localStorage cache.
26
+ * @property {string} [volumeStorageKey='tiny_media_player_volume'] - The specific key name used for localStorage cache.
27
+ * @property {boolean} [repeatCurrentOnPrev=false] - If true, clicking 'previous' repeats the current track on the first click.
28
+ * @property {boolean} [smoothPlayPauseVolume=false] - If true, volume fades smoothly during play/pause transitions.
29
+ * @property {boolean} [smoothStopVolume=false] - If true, volume fades smoothly to zero when stopping.
24
30
  */
25
- /**
26
- * Interface definition for a Media Provider Adapter.
27
- * All specific API wrappers must extend and implement this class.
28
- * @abstract
29
- */
30
- class BaseMediaAdapter {
31
- constructor() {
32
- if (new.target === BaseMediaAdapter) {
33
- throw new Error('BaseMediaAdapter is an abstract class and cannot be instantiated directly.');
34
- }
35
- }
36
- /**
37
- * Determines if this adapter can play the provided content.
38
- * @param {MediaContent} content - The media content to evaluate.
39
- * @returns {boolean} True if the adapter can handle the content, false otherwise.
40
- */
41
- canHandle(content) {
42
- throw new Error('Method "canHandle" must be implemented by the subclass.');
43
- }
44
- /**
45
- * Starts or resumes playback of the provided content.
46
- * @param {MediaContent} content - The media content to play.
47
- * @returns {Promise<void>}
48
- */
49
- async play(content) {
50
- throw new Error('Method "play" must be implemented by the subclass.');
51
- }
52
- /**
53
- * Pauses the current playback.
54
- * @returns {Promise<void>}
55
- */
56
- async pause() {
57
- throw new Error('Method "pause" must be implemented by the subclass.');
58
- }
59
- /**
60
- * Stops the playback completely and resets the internal platform state.
61
- * @returns {Promise<void>}
62
- */
63
- async stop() {
64
- throw new Error('Method "stop" must be implemented by the subclass.');
65
- }
66
- /**
67
- * Seeks to a specific time in the media timeline.
68
- * @param {number} timeMs - The target time in milliseconds.
69
- * @returns {Promise<void>}
70
- */
71
- async seek(timeMs) {
72
- throw new Error('Method "seek" must be implemented by the subclass.');
73
- }
74
- /**
75
- * Retrieves the current playback time from the underlying API.
76
- * @returns {number} The current time in milliseconds.
77
- */
78
- getCurrentTime() {
79
- throw new Error('Method "getCurrentTime" must be implemented by the subclass.');
80
- }
81
- /**
82
- * Sets the playback volume for the underlying API.
83
- * @param {number} volume - The volume level from 0.0 to 1.0.
84
- * @returns {void}
85
- */
86
- setVolume(volume) {
87
- throw new Error('Method "setVolume" must be implemented by the subclass.');
88
- }
89
- }
90
31
  /**
91
32
  * Represents a search match containing the media object and its exact playlist index.
92
33
  * @typedef {Object} SearchResult
@@ -117,7 +58,7 @@ class TinyMediaPlayer extends EventEmitter {
117
58
  */
118
59
  static set unknownArtist(value) {
119
60
  if (typeof value !== 'string' && typeof value !== 'function')
120
- throw new TypeError('unknownArtist must have an string or function.');
61
+ throw new TypeError('unknownArtist must be a string or a function.');
121
62
  TinyMediaPlayer.#unknownArtist = value;
122
63
  }
123
64
  /**
@@ -142,8 +83,10 @@ class TinyMediaPlayer extends EventEmitter {
142
83
  get destroyed() {
143
84
  return this.#destroyed;
144
85
  }
145
- /** @type {Map<string, BaseMediaAdapter>} */
146
- #adapters = new Map();
86
+ /** @type {Set<BaseMediaAdapter>} */
87
+ #adapters = new Set();
88
+ /** @type {WeakMap<BaseMediaAdapter, Map<string, (...args: any[]) => any>>} */
89
+ #adapterHandlers = new WeakMap();
147
90
  /** @type {MediaContent[]} */
148
91
  #playlist = [];
149
92
  /** @type {number} */
@@ -160,17 +103,168 @@ class TinyMediaPlayer extends EventEmitter {
160
103
  #persistVolume;
161
104
  /** @type {string} */
162
105
  #volumeStorageKey;
106
+ /** @type {boolean} */
107
+ #repeatCurrentOnPrev;
108
+ /** @type {boolean} */
109
+ #prevClickedToRepeat = false;
110
+ /** @type {boolean} */
111
+ #smoothPlayPauseVolume;
112
+ /** @type {boolean} */
113
+ #smoothStopVolume;
114
+ /** @type {AbortController | null} */
115
+ #fadeController = null;
116
+ /** @type {ReturnType<typeof setTimeout> | null} */
117
+ #prevClickTimer = null;
118
+ /** @type {number} */
119
+ #fadeVolumeSpeed = 300;
120
+ /** @returns {number} The duration (ms) of the volume fade speed. */
121
+ get fadeVolumeSpeed() {
122
+ checkDestroy(this.#destroyed);
123
+ return this.#fadeVolumeSpeed;
124
+ }
125
+ /**
126
+ * @param {number} value - The new duration in milliseconds.
127
+ * @throws {TypeError} If the value is not a number.
128
+ * @throws {RangeError} If the value is negative.
129
+ */
130
+ set fadeVolumeSpeed(value) {
131
+ checkDestroy(this.#destroyed);
132
+ if (typeof value !== 'number')
133
+ throw new TypeError('fadeVolumeSpeed must be a number.');
134
+ if (value < 0)
135
+ throw new RangeError('fadeVolumeSpeed cannot be negative.');
136
+ this.#fadeVolumeSpeed = value;
137
+ this.emit('fadeVolumeSpeedChange', this.#fadeVolumeSpeed);
138
+ }
139
+ /** @type {number} */
140
+ #prevClickTimeoutDuration = 2000;
141
+ /** @returns {number} The duration (ms) before the prev click state resets. */
142
+ get prevClickTimeoutDuration() {
143
+ checkDestroy(this.#destroyed);
144
+ return this.#prevClickTimeoutDuration;
145
+ }
146
+ /**
147
+ * @param {number} value - The new duration in milliseconds.
148
+ * @throws {TypeError} If the value is not a number.
149
+ * @throws {RangeError} If the value is negative.
150
+ */
151
+ set prevClickTimeoutDuration(value) {
152
+ checkDestroy(this.#destroyed);
153
+ if (typeof value !== 'number')
154
+ throw new TypeError('prevClickTimeoutDuration must be a number.');
155
+ if (value < 0)
156
+ throw new RangeError('prevClickTimeoutDuration cannot be negative.');
157
+ this.#prevClickTimeoutDuration = value;
158
+ this.emit('prevClickTimeoutDurationChange', this.#prevClickTimeoutDuration);
159
+ }
160
+ /** @returns {boolean} True if clicking 'previous' repeats the current track on the first click. */
161
+ get repeatCurrentOnPrev() {
162
+ checkDestroy(this.#destroyed);
163
+ return this.#repeatCurrentOnPrev;
164
+ }
165
+ /**
166
+ * @param {boolean} value - The repeat mode state.
167
+ * @throws {TypeError} If the value is not a boolean.
168
+ */
169
+ set repeatCurrentOnPrev(value) {
170
+ checkDestroy(this.#destroyed);
171
+ if (typeof value !== 'boolean') {
172
+ throw new TypeError('repeatCurrentOnPrev must be a boolean.');
173
+ }
174
+ this.#repeatCurrentOnPrev = value;
175
+ this.emit('repeatCurrentOnPrevChange', this.#repeatCurrentOnPrev);
176
+ }
177
+ /** @returns {boolean} True if volume fades smoothly during play/pause transitions. */
178
+ get smoothPlayPauseVolume() {
179
+ checkDestroy(this.#destroyed);
180
+ return this.#smoothPlayPauseVolume;
181
+ }
182
+ /**
183
+ * @param {boolean} value - The smooth play/pause volume mode.
184
+ * @throws {TypeError} If the value is not a boolean.
185
+ */
186
+ set smoothPlayPauseVolume(value) {
187
+ checkDestroy(this.#destroyed);
188
+ if (typeof value !== 'boolean') {
189
+ throw new TypeError('smoothPlayPauseVolume must be a boolean.');
190
+ }
191
+ this.#smoothPlayPauseVolume = value;
192
+ this.emit('smoothPlayPauseVolumeChange', this.#smoothPlayPauseVolume);
193
+ }
194
+ /** @returns {boolean} True if volume fades smoothly to zero when stopping. */
195
+ get smoothStopVolume() {
196
+ checkDestroy(this.#destroyed);
197
+ return this.#smoothStopVolume;
198
+ }
199
+ /**
200
+ * @param {boolean} value - The smooth stop volume mode.
201
+ * @throws {TypeError} If the value is not a boolean.
202
+ */
203
+ set smoothStopVolume(value) {
204
+ checkDestroy(this.#destroyed);
205
+ if (typeof value !== 'boolean') {
206
+ throw new TypeError('smoothStopVolume must be a boolean.');
207
+ }
208
+ this.#smoothStopVolume = value;
209
+ this.emit('smoothStopVolumeChange', this.#smoothStopVolume);
210
+ }
163
211
  /**
164
212
  * @param {TinyMediaPlayerOptions} [options={}] - Configuration parameters for the player.
213
+ * @throws {TypeError} If options is not a valid object.
214
+ * @throws {TypeError} If option values do not match their expected types.
215
+ * @throws {RangeError} If numeric options are out of valid ranges.
165
216
  */
166
217
  constructor(options = {}) {
167
218
  super();
219
+ if (!isValidObj(options)) {
220
+ throw new TypeError('Options must be a non-null object.');
221
+ }
168
222
  // Volume configuration
169
- this.#persistVolume = Boolean(options.persistVolume);
170
- this.#volumeStorageKey =
171
- typeof options.volumeStorageKey === 'string'
172
- ? options.volumeStorageKey
173
- : 'tiny_media_player_volume';
223
+ if (options.persistVolume !== undefined) {
224
+ if (typeof options.persistVolume !== 'boolean') {
225
+ throw new TypeError('persistVolume must be a boolean.');
226
+ }
227
+ this.#persistVolume = options.persistVolume;
228
+ }
229
+ else {
230
+ this.#persistVolume = false;
231
+ }
232
+ if (options.volumeStorageKey !== undefined) {
233
+ if (typeof options.volumeStorageKey !== 'string')
234
+ throw new TypeError('volumeStorageKey must be a string.');
235
+ this.#volumeStorageKey = options.volumeStorageKey;
236
+ }
237
+ else {
238
+ this.#volumeStorageKey = 'tiny_media_player_volume';
239
+ }
240
+ // UX Options
241
+ if (options.repeatCurrentOnPrev !== undefined) {
242
+ if (typeof options.repeatCurrentOnPrev !== 'boolean') {
243
+ throw new TypeError('repeatCurrentOnPrev must be a boolean.');
244
+ }
245
+ this.#repeatCurrentOnPrev = options.repeatCurrentOnPrev;
246
+ }
247
+ else {
248
+ this.#repeatCurrentOnPrev = false;
249
+ }
250
+ if (options.smoothPlayPauseVolume !== undefined) {
251
+ if (typeof options.smoothPlayPauseVolume !== 'boolean') {
252
+ throw new TypeError('smoothPlayPauseVolume must be a boolean.');
253
+ }
254
+ this.#smoothPlayPauseVolume = options.smoothPlayPauseVolume;
255
+ }
256
+ else {
257
+ this.#smoothPlayPauseVolume = false;
258
+ }
259
+ if (options.smoothStopVolume !== undefined) {
260
+ if (typeof options.smoothStopVolume !== 'boolean') {
261
+ throw new TypeError('smoothStopVolume must be a boolean.');
262
+ }
263
+ this.#smoothStopVolume = options.smoothStopVolume;
264
+ }
265
+ else {
266
+ this.#smoothStopVolume = false;
267
+ }
174
268
  if (this.#persistVolume)
175
269
  this.#loadVolumeFromStorage();
176
270
  }
@@ -248,6 +342,44 @@ class TinyMediaPlayer extends EventEmitter {
248
342
  // Fallback in case of floating point inaccuracies
249
343
  return this.#currentIndex === this.#playlist.length - 1 ? 0 : this.#playlist.length - 1;
250
344
  }
345
+ /**
346
+ * Handles smooth volume transitions for the active adapter.
347
+ * @param {number} targetVolume - The volume to transition to.
348
+ * @returns {Promise<void>}
349
+ */
350
+ async #fadeAdapterVolume(targetVolume) {
351
+ if (this.#fadeController) {
352
+ this.#fadeController.abort();
353
+ }
354
+ this.#fadeController = new AbortController();
355
+ const { signal } = this.#fadeController;
356
+ const adapter = this.#getActiveAdapter();
357
+ if (!adapter)
358
+ return;
359
+ const startVolume = adapter.getVolume();
360
+ const startTime = performance.now();
361
+ const duration = this.#fadeVolumeSpeed;
362
+ return new Promise((resolve) => {
363
+ /** @param {number} currentTime */
364
+ const step = (currentTime) => {
365
+ if (signal.aborted)
366
+ return resolve();
367
+ const elapsed = currentTime - startTime;
368
+ const progress = Math.min(elapsed / duration, 1);
369
+ const calculatedVolume = startVolume + (targetVolume - startVolume) * progress;
370
+ const clampedVolume = Math.max(0, Math.min(1, calculatedVolume));
371
+ adapter.setVolume(clampedVolume);
372
+ if (progress < 1) {
373
+ requestAnimationFrame(step);
374
+ }
375
+ else {
376
+ adapter.setVolume(targetVolume);
377
+ resolve();
378
+ }
379
+ };
380
+ requestAnimationFrame(step);
381
+ });
382
+ }
251
383
  // ==========================================
252
384
  // GETTERS & STRICT SETTERS
253
385
  // ==========================================
@@ -408,6 +540,9 @@ class TinyMediaPlayer extends EventEmitter {
408
540
  throw new TypeError('Volume must be a number.');
409
541
  if (value < 0 || value > 1)
410
542
  throw new RangeError('Volume must be tightly constrained between 0.0 and 1.0.');
543
+ // Abort any ongoing fade to ensure manual control is immediate
544
+ if (this.#fadeController)
545
+ this.#fadeController.abort();
411
546
  this.#volume = value;
412
547
  this.#saveVolumeToStorage();
413
548
  this.emit('volumeChange', this.#volume);
@@ -475,21 +610,135 @@ class TinyMediaPlayer extends EventEmitter {
475
610
  // ==========================================
476
611
  // ADAPTER MANAGEMENT
477
612
  // ==========================================
613
+ get adapters() {
614
+ return Array.from(this.#adapters);
615
+ }
616
+ get adaptersSize() {
617
+ return this.#adapters.size;
618
+ }
619
+ /** @type {string[]} */
620
+ static #defaultAdapterEventNames = ['timeupdate', 'ended'];
621
+ static get defaultAdapterEventNames() {
622
+ return structuredClone(TinyMediaPlayer.#defaultAdapterEventNames);
623
+ }
624
+ /** @param {string[]} value */
625
+ static set defaultAdapterEventNames(value) {
626
+ if (!Array.isArray(value) || !value.every((v) => typeof v === 'string'))
627
+ throw new TypeError('TinyMediaPlayer.defaultAdapterEventNames must be an array of strings.');
628
+ TinyMediaPlayer.#defaultAdapterEventNames = [...value];
629
+ }
630
+ #adapterEventNames = new Set(TinyMediaPlayer.defaultAdapterEventNames);
631
+ /**
632
+ * Removes the timeupdate on adapter emitter.
633
+ * @param {BaseMediaAdapter} adapter
634
+ */
635
+ #removeHandler(adapter) {
636
+ const data = this.#adapterHandlers.get(adapter);
637
+ if (!data)
638
+ return;
639
+ this.#adapterEventNames.forEach((eventName) => {
640
+ const handler = data.get(eventName);
641
+ if (handler)
642
+ adapter.off(eventName, handler);
643
+ });
644
+ data.clear();
645
+ this.#adapterHandlers.delete(adapter);
646
+ }
478
647
  /**
479
648
  * Registers a new media API adapter.
480
- * @param {string} id - Unique identifier for the platform (e.g., 'youtube', 'spotify').
481
649
  * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
482
- * @throws {TypeError} If id is not a string or adapter is invalid.
650
+ * @throws {TypeError} If the adapter is invalid.
483
651
  */
484
- registerAdapter(id, adapter) {
652
+ registerAdapter(adapter) {
485
653
  checkDestroy(this.#destroyed);
486
- if (typeof id !== 'string') {
487
- throw new TypeError('Adapter ID must be a string.');
654
+ if (!(adapter instanceof BaseMediaAdapter)) {
655
+ throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
488
656
  }
657
+ const events = new Map();
658
+ this.#adapterHandlers.set(adapter, events);
659
+ // Use the controlled helper to ensure each event is registered
660
+ // and correctly mapped in #adapterHandlers from the start.
661
+ this.#adapterEventNames.forEach((eventName) => {
662
+ this.#attachEventToAdapter(adapter, eventName);
663
+ });
664
+ this.#adapters.add(adapter);
665
+ }
666
+ /**
667
+ * Removes a media API adapter.
668
+ * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
669
+ * @throws {TypeError} If adapter is invalid.
670
+ */
671
+ removeAdapter(adapter) {
672
+ checkDestroy(this.#destroyed);
673
+ if (!(adapter instanceof BaseMediaAdapter)) {
674
+ throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
675
+ }
676
+ this.#removeHandler(adapter);
677
+ return this.#adapters.delete(adapter);
678
+ }
679
+ /**
680
+ * Destroys and removes a media API adapter.
681
+ * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
682
+ * @throws {TypeError} If adapter is invalid.
683
+ */
684
+ destroyAdapter(adapter) {
685
+ checkDestroy(this.#destroyed);
686
+ if (!(adapter instanceof BaseMediaAdapter)) {
687
+ throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
688
+ }
689
+ this.#removeHandler(adapter);
690
+ const result = this.#adapters.delete(adapter);
691
+ if (result)
692
+ adapter.destroy();
693
+ return result;
694
+ }
695
+ /**
696
+ * If exists a media API adapter.
697
+ * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
698
+ * @throws {TypeError} If adapter is invalid.
699
+ */
700
+ hasAdapter(adapter) {
701
+ checkDestroy(this.#destroyed);
489
702
  if (!(adapter instanceof BaseMediaAdapter)) {
490
703
  throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
491
704
  }
492
- this.#adapters.set(id, adapter);
705
+ return this.#adapters.has(adapter);
706
+ }
707
+ /**
708
+ * Destroys and removes all registered media adapters.
709
+ */
710
+ destroyAllAdapters() {
711
+ checkDestroy(this.#destroyed);
712
+ this.#adapters.forEach((adapter) => {
713
+ this.#removeHandler(adapter);
714
+ adapter.destroy();
715
+ });
716
+ this.#adapters.clear();
717
+ }
718
+ /**
719
+ * Clears all registered media adapters.
720
+ */
721
+ clearAdapters() {
722
+ checkDestroy(this.#destroyed);
723
+ this.#adapters.forEach((adapter) => this.#removeHandler(adapter));
724
+ this.#adapters.clear();
725
+ }
726
+ /**
727
+ * Helper to find the correct API wrapper for the content.
728
+ * @param {MediaContent} content - The content to check.
729
+ * @returns {BaseMediaAdapter | null} The compatible adapter, or null if empty.
730
+ * @throws {TypeError} If the content is invalid.
731
+ */
732
+ getMediaAdapter(content) {
733
+ if (!isValidObj(content)) {
734
+ throw new TypeError('Content must be a valid object.');
735
+ }
736
+ for (const adapter of this.#adapters.values()) {
737
+ if (adapter.canHandle(content)) {
738
+ return adapter;
739
+ }
740
+ }
741
+ return null;
493
742
  }
494
743
  /**
495
744
  * Internal helper to find the correct API wrapper for the current content.
@@ -500,12 +749,106 @@ class TinyMediaPlayer extends EventEmitter {
500
749
  if (this.#currentIndex === -1 || this.#playlist.length === 0)
501
750
  return null;
502
751
  const currentContent = this.#playlist[this.#currentIndex];
503
- for (const adapter of this.#adapters.values()) {
504
- if (adapter.canHandle(currentContent)) {
505
- return adapter;
752
+ const adapter = this.getMediaAdapter(currentContent);
753
+ if (adapter)
754
+ return adapter;
755
+ throw new Error(`No compatible adapter found for content ID: ${currentContent.id}.`);
756
+ }
757
+ // ==========================================
758
+ // EVENT MANAGEMENT (CONTROLLED)
759
+ // ==========================================
760
+ /**
761
+ * Adds a new event to be listened to on the adapters.
762
+ * @param {string} eventName - The name of the event to add.
763
+ * @throws {TypeError} If the event name is not a string.
764
+ */
765
+ addAdapterEvent(eventName) {
766
+ checkDestroy(this.#destroyed);
767
+ if (typeof eventName !== 'string') {
768
+ throw new TypeError('Event name must be a string.');
769
+ }
770
+ // If the event is already in the list, do nothing to avoid duplicates
771
+ if (this.#adapterEventNames.has(eventName))
772
+ return;
773
+ this.#adapterEventNames.add(eventName);
774
+ // Synchronize: Add the listener to all already registered adapters
775
+ this.#adapters.forEach((adapter) => {
776
+ this.#attachEventToAdapter(adapter, eventName);
777
+ });
778
+ }
779
+ /**
780
+ * Removes an event from the list of listened events.
781
+ * @param {string} eventName - The name of the event to remove.
782
+ * @throws {TypeError} If the event name is not a string.
783
+ */
784
+ removeAdapterEvent(eventName) {
785
+ checkDestroy(this.#destroyed);
786
+ if (typeof eventName !== 'string') {
787
+ throw new TypeError('Event name must be a string.');
788
+ }
789
+ if (!this.#adapterEventNames.has(eventName))
790
+ return;
791
+ this.#adapterEventNames.delete(eventName);
792
+ // Synchronize: Remove the listener from all registered adapters
793
+ this.#adapters.forEach((adapter) => {
794
+ this.#detachEventFromAdapter(adapter, eventName);
795
+ });
796
+ }
797
+ /**
798
+ * Resets the event list to the factory default state: ['timeupdate', 'ended'].
799
+ * @throws {TypeError} If the event name is not a string.
800
+ */
801
+ resetAdapterEvents() {
802
+ checkDestroy(this.#destroyed);
803
+ const defaultEvents = TinyMediaPlayer.defaultAdapterEventNames;
804
+ // Convert to Array to avoid issues when deleting items during iteration
805
+ const currentEvents = Array.from(this.#adapterEventNames);
806
+ // 1. Remove events that are not part of the factory default
807
+ for (const eventName of currentEvents) {
808
+ if (!defaultEvents.includes(eventName)) {
809
+ this.removeAdapterEvent(eventName);
810
+ }
811
+ }
812
+ // 2. Ensure default events exist (in case they were previously removed)
813
+ for (const defaultEvent of defaultEvents) {
814
+ if (!this.#adapterEventNames.has(defaultEvent)) {
815
+ this.addAdapterEvent(defaultEvent);
506
816
  }
507
817
  }
508
- throw new Error(`No compatible adapter found for content ID: ${currentContent.id}.`);
818
+ }
819
+ /**
820
+ * Private helper to attach an event to a specific adapter and save the handler.
821
+ * @param {BaseMediaAdapter} adapter - The destination adapter.
822
+ * @param {string} eventName - The event name.
823
+ */
824
+ #attachEventToAdapter(adapter, eventName) {
825
+ const handlersMap = this.#adapterHandlers.get(adapter);
826
+ if (!handlersMap)
827
+ return;
828
+ // Create an encapsulated handler to maintain security (checkDestroy)
829
+ /** @type {(...args: any[]) => any} */
830
+ const handler = (...args) => {
831
+ if (!this.#destroyed) {
832
+ this.emit(eventName, ...args);
833
+ }
834
+ };
835
+ adapter.on(eventName, handler);
836
+ handlersMap.set(eventName, handler); // Mandatory synchronization with the WeakMap
837
+ }
838
+ /**
839
+ * Private helper to remove an event from a specific adapter using the saved handler.
840
+ * @param {BaseMediaAdapter} adapter - The destination adapter.
841
+ * @param {string} eventName - The event name.
842
+ */
843
+ #detachEventFromAdapter(adapter, eventName) {
844
+ const handlersMap = this.#adapterHandlers.get(adapter);
845
+ if (!handlersMap)
846
+ return;
847
+ const handler = handlersMap.get(eventName);
848
+ if (handler) {
849
+ adapter.off(eventName, handler);
850
+ handlersMap.delete(eventName); // Mandatory synchronization with the WeakMap
851
+ }
509
852
  }
510
853
  // ==========================================
511
854
  // PLAYLIST MANAGEMENT
@@ -518,8 +861,9 @@ class TinyMediaPlayer extends EventEmitter {
518
861
  */
519
862
  addTrack(content) {
520
863
  checkDestroy(this.#destroyed);
521
- if (!content || typeof content !== 'object' || typeof content.url !== 'string')
864
+ if (!isValidObj(content) || typeof content.url !== 'string') {
522
865
  throw new TypeError('Track content must be a valid MediaContent object containing a URL.');
866
+ }
523
867
  /** @type {MediaContent} */
524
868
  const newContent = { ...getMediaContentBase(), ...getMediaContentMetadata(), ...content };
525
869
  valMediaContentMetadata(newContent);
@@ -534,7 +878,7 @@ class TinyMediaPlayer extends EventEmitter {
534
878
  /**
535
879
  * Checks if a track exists at the specified index.
536
880
  * @param {number} index - The target index.
537
- * @returns {boolean} True if the track exists, false otherwise.
881
+ * @returns {boolean} True if the track exists at the specified index.
538
882
  * @throws {TypeError} If the index is not a number.
539
883
  */
540
884
  existsTrack(index) {
@@ -549,7 +893,7 @@ class TinyMediaPlayer extends EventEmitter {
549
893
  * @param {number} index - The target index.
550
894
  * @returns {MediaContent} The media content object.
551
895
  * @throws {TypeError} If the index is not a number.
552
- * @throws {RangeError} If the index is out of bounds.
896
+ * @throws {RangeError} If the index is out of the playlist bounds.
553
897
  */
554
898
  getTrack(index) {
555
899
  checkDestroy(this.#destroyed);
@@ -585,7 +929,7 @@ class TinyMediaPlayer extends EventEmitter {
585
929
  }
586
930
  else if (this.#currentIndex >= this.#playlist.length) {
587
931
  // If we removed the last item and others exist, reset index safely
588
- this.#currentIndex = 0;
932
+ this.#currentIndex = 0; // Reset to start if out of bounds
589
933
  }
590
934
  this.emit('trackChange', this.#currentIndex);
591
935
  }
@@ -655,7 +999,12 @@ class TinyMediaPlayer extends EventEmitter {
655
999
  if (!adapter)
656
1000
  return;
657
1001
  // Ensure the adapter aligns with the global volume before playing
658
- adapter.setVolume(this.#volume);
1002
+ if (this.#smoothPlayPauseVolume) {
1003
+ await this.#fadeAdapterVolume(this.#volume);
1004
+ }
1005
+ else {
1006
+ adapter.setVolume(this.#volume);
1007
+ }
659
1008
  await adapter.play(this.#playlist[this.#currentIndex]);
660
1009
  this.#isPlaying = true;
661
1010
  this.emit('play', this.#currentIndex);
@@ -669,6 +1018,9 @@ class TinyMediaPlayer extends EventEmitter {
669
1018
  const adapter = this.#getActiveAdapter();
670
1019
  if (!adapter)
671
1020
  return;
1021
+ if (this.#smoothPlayPauseVolume) {
1022
+ await this.#fadeAdapterVolume(0);
1023
+ }
672
1024
  await adapter.pause();
673
1025
  this.#isPlaying = false;
674
1026
  this.emit('pause', this.#currentIndex);
@@ -682,6 +1034,9 @@ class TinyMediaPlayer extends EventEmitter {
682
1034
  const adapter = this.#getActiveAdapter();
683
1035
  if (!adapter)
684
1036
  return;
1037
+ if (this.#smoothStopVolume) {
1038
+ await this.#fadeAdapterVolume(0);
1039
+ }
685
1040
  await adapter.stop();
686
1041
  this.#isPlaying = false;
687
1042
  this.emit('stop', this.#currentIndex);
@@ -727,6 +1082,26 @@ class TinyMediaPlayer extends EventEmitter {
727
1082
  checkDestroy(this.#destroyed);
728
1083
  if (this.#playlist.length === 0)
729
1084
  return;
1085
+ // UX: Repeat current track if "repeat on prev" is enabled and it's the first click
1086
+ if (this.#repeatCurrentOnPrev && !this.#prevClickedToRepeat) {
1087
+ this.#prevClickedToRepeat = true;
1088
+ // Reset the "repeat" state after 2 seconds of inactivity to prevent glitches
1089
+ if (this.#prevClickTimer)
1090
+ clearTimeout(this.#prevClickTimer);
1091
+ this.#prevClickTimer = setTimeout(() => {
1092
+ this.#prevClickedToRepeat = false;
1093
+ this.#prevClickTimer = null;
1094
+ }, 2000);
1095
+ await this.stop();
1096
+ await this.play();
1097
+ return;
1098
+ }
1099
+ // If we reached here, it's either the second click or repeat is disabled
1100
+ if (this.#prevClickTimer) {
1101
+ clearTimeout(this.#prevClickTimer);
1102
+ this.#prevClickTimer = null;
1103
+ }
1104
+ this.#prevClickedToRepeat = false;
730
1105
  await this.stop();
731
1106
  if (this.#isRandom) {
732
1107
  this.#currentIndex = this._getWeightedRandomIndex();
@@ -750,9 +1125,9 @@ class TinyMediaPlayer extends EventEmitter {
750
1125
  /**
751
1126
  * Jumps to a specific absolute time in the timeline.
752
1127
  * @param {number} timeMs - The target time in milliseconds.
1128
+ * @returns {Promise<void>}
753
1129
  * @throws {TypeError} If timeMs is not a number.
754
1130
  * @throws {RangeError} If timeMs is negative.
755
- * @returns {Promise<void>}
756
1131
  */
757
1132
  async seek(timeMs) {
758
1133
  checkDestroy(this.#destroyed);
@@ -789,6 +1164,68 @@ class TinyMediaPlayer extends EventEmitter {
789
1164
  targetTime = 0;
790
1165
  await this.seek(targetTime);
791
1166
  }
1167
+ // ==========================================
1168
+ // TIME UTILITIES (DELEGATED TO ADAPTERS)
1169
+ // ==========================================
1170
+ /**
1171
+ * Gets the current playback time.
1172
+ * @returns {number} The current time in milliseconds.
1173
+ */
1174
+ getCurrentTime() {
1175
+ checkDestroy(this.#destroyed);
1176
+ try {
1177
+ const adapter = this.#getActiveAdapter();
1178
+ return adapter ? adapter.getCurrentTime() : 0;
1179
+ }
1180
+ catch {
1181
+ return 0;
1182
+ }
1183
+ }
1184
+ /**
1185
+ * Gets the total duration of the current track.
1186
+ * @returns {number} The total duration in milliseconds.
1187
+ */
1188
+ getTotalDuration() {
1189
+ checkDestroy(this.#destroyed);
1190
+ try {
1191
+ const adapter = this.#getActiveAdapter();
1192
+ return adapter ? adapter.getTotalDuration() : 0;
1193
+ }
1194
+ catch {
1195
+ return 0;
1196
+ }
1197
+ }
1198
+ /**
1199
+ * Gets the remaining time until the current track ends.
1200
+ * @returns {number} The remaining time in milliseconds.
1201
+ */
1202
+ getRemainingTime() {
1203
+ checkDestroy(this.#destroyed);
1204
+ try {
1205
+ const adapter = this.#getActiveAdapter();
1206
+ return adapter ? adapter.getRemainingTime() : 0;
1207
+ }
1208
+ catch {
1209
+ return 0;
1210
+ }
1211
+ }
1212
+ /**
1213
+ * Gets the percentage of the current track that has been played.
1214
+ * @returns {number} A value between 0 and 100.
1215
+ */
1216
+ getPlaybackPercentage() {
1217
+ checkDestroy(this.#destroyed);
1218
+ try {
1219
+ const adapter = this.#getActiveAdapter();
1220
+ return adapter ? adapter.getPlaybackPercentage() : 0;
1221
+ }
1222
+ catch {
1223
+ return 0;
1224
+ }
1225
+ }
1226
+ // ==========================================
1227
+ // DESTROYER
1228
+ // ==========================================
792
1229
  /**
793
1230
  * Safely destroys the TinyMediaPlayer instance.
794
1231
  * This method stops active playback, clears the internal playlist,
@@ -809,15 +1246,20 @@ class TinyMediaPlayer extends EventEmitter {
809
1246
  catch (error) {
810
1247
  console.warn('[TinyMediaPlayer] Non-fatal error during playback termination:', error);
811
1248
  }
1249
+ if (this.#prevClickTimer)
1250
+ clearTimeout(this.#prevClickTimer);
812
1251
  // 2. Clear internal state variables
813
1252
  this.#playlist = [];
814
1253
  this.#currentIndex = -1;
815
1254
  this.#isPlaying = false;
816
- // 3. Clear all registered API adapters
817
- this.#adapters.clear();
1255
+ this.#prevClickedToRepeat = false;
1256
+ // 3. Clears and destroys all registered API adapters
1257
+ this.destroyAllAdapters();
818
1258
  // 4. Remove all event listeners inherited from EventEmitter
819
1259
  this.removeAllListeners();
1260
+ this.#adapterEventNames.clear();
820
1261
  this.#destroyed = true;
1262
+ this.emit('destroyed');
821
1263
  }
822
1264
  }
823
1265
  export default TinyMediaPlayer;