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
@@ -3,6 +3,8 @@
3
3
  var events = require('events');
4
4
  var mediaContent = require('../basics/mediaContent.cjs');
5
5
  var utils = require('./utils.cjs');
6
+ var index = require('./TinyMediaPlayer/index.cjs');
7
+ var objChecker = require('../basics/objChecker.cjs');
6
8
 
7
9
  const checkDestroy = utils.createCheckDestroyed('TinyMediaPlayer');
8
10
 
@@ -15,6 +17,7 @@ const checkDestroy = utils.createCheckDestroyed('TinyMediaPlayer');
15
17
  * @typedef {import('../basics/mediaContent.mjs').LoadingMediaProgress} LoadingMediaProgress
16
18
  * @typedef {import('../basics/mediaContent.mjs').MediaLoadingErrorData} MediaLoadingErrorData
17
19
  * @typedef {import('../basics/mediaContent.mjs').UnknownArtistGetter} UnknownArtistGetter
20
+ * @typedef {import('./TinyMediaPlayer/index.mjs').ContentTimeData} ContentTimeData
18
21
  */
19
22
 
20
23
  /**
@@ -23,85 +26,15 @@ const checkDestroy = utils.createCheckDestroyed('TinyMediaPlayer');
23
26
  */
24
27
 
25
28
  /**
26
- * Configuration options for initializing the UniversalMediaPlayer.
29
+ * Configuration options for initializing the TinyMediaPlayer.
27
30
  * @typedef {Object} TinyMediaPlayerOptions
28
31
  * @property {boolean} [persistVolume=false] - Whether to automatically save the volume in localStorage.
29
- * @property {string} [volumeStorageKey='universal_media_player_volume'] - The specific key name used for localStorage cache.
32
+ * @property {string} [volumeStorageKey='tiny_media_player_volume'] - The specific key name used for localStorage cache.
33
+ * @property {boolean} [repeatCurrentOnPrev=false] - If true, clicking 'previous' repeats the current track on the first click.
34
+ * @property {boolean} [smoothPlayPauseVolume=false] - If true, volume fades smoothly during play/pause transitions.
35
+ * @property {boolean} [smoothStopVolume=false] - If true, volume fades smoothly to zero when stopping.
30
36
  */
31
37
 
32
- /**
33
- * Interface definition for a Media Provider Adapter.
34
- * All specific API wrappers must extend and implement this class.
35
- * @abstract
36
- */
37
- class BaseMediaAdapter {
38
- constructor() {
39
- if (new.target === BaseMediaAdapter) {
40
- throw new Error('BaseMediaAdapter is an abstract class and cannot be instantiated directly.');
41
- }
42
- }
43
-
44
- /**
45
- * Determines if this adapter can play the provided content.
46
- * @param {MediaContent} content - The media content to evaluate.
47
- * @returns {boolean} True if the adapter can handle the content, false otherwise.
48
- */
49
- canHandle(content) {
50
- throw new Error('Method "canHandle" must be implemented by the subclass.');
51
- }
52
-
53
- /**
54
- * Starts or resumes playback of the provided content.
55
- * @param {MediaContent} content - The media content to play.
56
- * @returns {Promise<void>}
57
- */
58
- async play(content) {
59
- throw new Error('Method "play" must be implemented by the subclass.');
60
- }
61
-
62
- /**
63
- * Pauses the current playback.
64
- * @returns {Promise<void>}
65
- */
66
- async pause() {
67
- throw new Error('Method "pause" must be implemented by the subclass.');
68
- }
69
-
70
- /**
71
- * Stops the playback completely and resets the internal platform state.
72
- * @returns {Promise<void>}
73
- */
74
- async stop() {
75
- throw new Error('Method "stop" must be implemented by the subclass.');
76
- }
77
-
78
- /**
79
- * Seeks to a specific time in the media timeline.
80
- * @param {number} timeMs - The target time in milliseconds.
81
- * @returns {Promise<void>}
82
- */
83
- async seek(timeMs) {
84
- throw new Error('Method "seek" must be implemented by the subclass.');
85
- }
86
-
87
- /**
88
- * Retrieves the current playback time from the underlying API.
89
- * @returns {number} The current time in milliseconds.
90
- */
91
- getCurrentTime() {
92
- throw new Error('Method "getCurrentTime" must be implemented by the subclass.');
93
- }
94
-
95
- /**
96
- * Sets the playback volume for the underlying API.
97
- * @param {number} volume - The volume level from 0.0 to 1.0.
98
- * @returns {void}
99
- */
100
- setVolume(volume) {
101
- throw new Error('Method "setVolume" must be implemented by the subclass.');
102
- }
103
- }
104
-
105
38
  /**
106
39
  * Represents a search match containing the media object and its exact playlist index.
107
40
  * @typedef {Object} SearchResult
@@ -113,7 +46,7 @@ class BaseMediaAdapter {
113
46
  * A universal media player manager capable of orchestrating multiple API adapters.
114
47
  */
115
48
  class TinyMediaPlayer extends events.EventEmitter {
116
- static BaseMediaAdapter = BaseMediaAdapter;
49
+ static BaseMediaAdapter = index.BaseMediaAdapter;
117
50
 
118
51
  /**
119
52
  * @type {UnknownArtistGetter}
@@ -136,7 +69,7 @@ class TinyMediaPlayer extends events.EventEmitter {
136
69
  */
137
70
  static set unknownArtist(value) {
138
71
  if (typeof value !== 'string' && typeof value !== 'function')
139
- throw new TypeError('unknownArtist must have an string or function.');
72
+ throw new TypeError('unknownArtist must be a string or a function.');
140
73
  TinyMediaPlayer.#unknownArtist = value;
141
74
  }
142
75
 
@@ -172,8 +105,11 @@ class TinyMediaPlayer extends events.EventEmitter {
172
105
  return this.#destroyed;
173
106
  }
174
107
 
175
- /** @type {Map<string, BaseMediaAdapter>} */
176
- #adapters = new Map();
108
+ /** @type {Set<BaseMediaAdapter>} */
109
+ #adapters = new Set();
110
+
111
+ /** @type {WeakMap<BaseMediaAdapter, Map<string, (...args: any[]) => any>>} */
112
+ #adapterHandlers = new WeakMap();
177
113
 
178
114
  /** @type {MediaContent[]} */
179
115
  #playlist = [];
@@ -199,17 +135,183 @@ class TinyMediaPlayer extends events.EventEmitter {
199
135
  /** @type {string} */
200
136
  #volumeStorageKey;
201
137
 
138
+ /** @type {boolean} */
139
+ #repeatCurrentOnPrev;
140
+
141
+ /** @type {boolean} */
142
+ #prevClickedToRepeat = false;
143
+
144
+ /** @type {boolean} */
145
+ #smoothPlayPauseVolume;
146
+
147
+ /** @type {boolean} */
148
+ #smoothStopVolume;
149
+
150
+ /** @type {AbortController | null} */
151
+ #fadeController = null;
152
+
153
+ /** @type {ReturnType<typeof setTimeout> | null} */
154
+ #prevClickTimer = null;
155
+
156
+ /** @type {number} */
157
+ #fadeVolumeSpeed = 300;
158
+
159
+ /** @returns {number} The duration (ms) of the volume fade speed. */
160
+ get fadeVolumeSpeed() {
161
+ checkDestroy(this.#destroyed);
162
+ return this.#fadeVolumeSpeed;
163
+ }
164
+
165
+ /**
166
+ * @param {number} value - The new duration in milliseconds.
167
+ * @throws {TypeError} If the value is not a number.
168
+ * @throws {RangeError} If the value is negative.
169
+ */
170
+ set fadeVolumeSpeed(value) {
171
+ checkDestroy(this.#destroyed);
172
+ if (typeof value !== 'number') throw new TypeError('fadeVolumeSpeed must be a number.');
173
+ if (value < 0) throw new RangeError('fadeVolumeSpeed cannot be negative.');
174
+ this.#fadeVolumeSpeed = value;
175
+ this.emit('fadeVolumeSpeedChange', this.#fadeVolumeSpeed);
176
+ }
177
+
178
+ /** @type {number} */
179
+ #prevClickTimeoutDuration = 2000;
180
+
181
+ /** @returns {number} The duration (ms) before the prev click state resets. */
182
+ get prevClickTimeoutDuration() {
183
+ checkDestroy(this.#destroyed);
184
+ return this.#prevClickTimeoutDuration;
185
+ }
186
+
187
+ /**
188
+ * @param {number} value - The new duration in milliseconds.
189
+ * @throws {TypeError} If the value is not a number.
190
+ * @throws {RangeError} If the value is negative.
191
+ */
192
+ set prevClickTimeoutDuration(value) {
193
+ checkDestroy(this.#destroyed);
194
+ if (typeof value !== 'number')
195
+ throw new TypeError('prevClickTimeoutDuration must be a number.');
196
+ if (value < 0) throw new RangeError('prevClickTimeoutDuration cannot be negative.');
197
+ this.#prevClickTimeoutDuration = value;
198
+ this.emit('prevClickTimeoutDurationChange', this.#prevClickTimeoutDuration);
199
+ }
200
+
201
+ /** @returns {boolean} True if clicking 'previous' repeats the current track on the first click. */
202
+ get repeatCurrentOnPrev() {
203
+ checkDestroy(this.#destroyed);
204
+ return this.#repeatCurrentOnPrev;
205
+ }
206
+
207
+ /**
208
+ * @param {boolean} value - The repeat mode state.
209
+ * @throws {TypeError} If the value is not a boolean.
210
+ */
211
+ set repeatCurrentOnPrev(value) {
212
+ checkDestroy(this.#destroyed);
213
+ if (typeof value !== 'boolean') {
214
+ throw new TypeError('repeatCurrentOnPrev must be a boolean.');
215
+ }
216
+ this.#repeatCurrentOnPrev = value;
217
+ this.emit('repeatCurrentOnPrevChange', this.#repeatCurrentOnPrev);
218
+ }
219
+
220
+ /** @returns {boolean} True if volume fades smoothly during play/pause transitions. */
221
+ get smoothPlayPauseVolume() {
222
+ checkDestroy(this.#destroyed);
223
+ return this.#smoothPlayPauseVolume;
224
+ }
225
+
226
+ /**
227
+ * @param {boolean} value - The smooth play/pause volume mode.
228
+ * @throws {TypeError} If the value is not a boolean.
229
+ */
230
+ set smoothPlayPauseVolume(value) {
231
+ checkDestroy(this.#destroyed);
232
+ if (typeof value !== 'boolean') {
233
+ throw new TypeError('smoothPlayPauseVolume must be a boolean.');
234
+ }
235
+ this.#smoothPlayPauseVolume = value;
236
+ this.emit('smoothPlayPauseVolumeChange', this.#smoothPlayPauseVolume);
237
+ }
238
+
239
+ /** @returns {boolean} True if volume fades smoothly to zero when stopping. */
240
+ get smoothStopVolume() {
241
+ checkDestroy(this.#destroyed);
242
+ return this.#smoothStopVolume;
243
+ }
244
+
245
+ /**
246
+ * @param {boolean} value - The smooth stop volume mode.
247
+ * @throws {TypeError} If the value is not a boolean.
248
+ */
249
+ set smoothStopVolume(value) {
250
+ checkDestroy(this.#destroyed);
251
+ if (typeof value !== 'boolean') {
252
+ throw new TypeError('smoothStopVolume must be a boolean.');
253
+ }
254
+ this.#smoothStopVolume = value;
255
+ this.emit('smoothStopVolumeChange', this.#smoothStopVolume);
256
+ }
257
+
202
258
  /**
203
259
  * @param {TinyMediaPlayerOptions} [options={}] - Configuration parameters for the player.
260
+ * @throws {TypeError} If options is not a valid object.
261
+ * @throws {TypeError} If option values do not match their expected types.
262
+ * @throws {RangeError} If numeric options are out of valid ranges.
204
263
  */
205
264
  constructor(options = {}) {
206
265
  super();
266
+ if (!objChecker.isValidObj(options)) {
267
+ throw new TypeError('Options must be a non-null object.');
268
+ }
269
+
207
270
  // Volume configuration
208
- this.#persistVolume = Boolean(options.persistVolume);
209
- this.#volumeStorageKey =
210
- typeof options.volumeStorageKey === 'string'
211
- ? options.volumeStorageKey
212
- : 'tiny_media_player_volume';
271
+ if (options.persistVolume !== undefined) {
272
+ if (typeof options.persistVolume !== 'boolean') {
273
+ throw new TypeError('persistVolume must be a boolean.');
274
+ }
275
+ this.#persistVolume = options.persistVolume;
276
+ } else {
277
+ this.#persistVolume = false;
278
+ }
279
+
280
+ if (options.volumeStorageKey !== undefined) {
281
+ if (typeof options.volumeStorageKey !== 'string')
282
+ throw new TypeError('volumeStorageKey must be a string.');
283
+ this.#volumeStorageKey = options.volumeStorageKey;
284
+ } else {
285
+ this.#volumeStorageKey = 'tiny_media_player_volume';
286
+ }
287
+
288
+ // UX Options
289
+ if (options.repeatCurrentOnPrev !== undefined) {
290
+ if (typeof options.repeatCurrentOnPrev !== 'boolean') {
291
+ throw new TypeError('repeatCurrentOnPrev must be a boolean.');
292
+ }
293
+ this.#repeatCurrentOnPrev = options.repeatCurrentOnPrev;
294
+ } else {
295
+ this.#repeatCurrentOnPrev = false;
296
+ }
297
+
298
+ if (options.smoothPlayPauseVolume !== undefined) {
299
+ if (typeof options.smoothPlayPauseVolume !== 'boolean') {
300
+ throw new TypeError('smoothPlayPauseVolume must be a boolean.');
301
+ }
302
+ this.#smoothPlayPauseVolume = options.smoothPlayPauseVolume;
303
+ } else {
304
+ this.#smoothPlayPauseVolume = false;
305
+ }
306
+
307
+ if (options.smoothStopVolume !== undefined) {
308
+ if (typeof options.smoothStopVolume !== 'boolean') {
309
+ throw new TypeError('smoothStopVolume must be a boolean.');
310
+ }
311
+ this.#smoothStopVolume = options.smoothStopVolume;
312
+ } else {
313
+ this.#smoothStopVolume = false;
314
+ }
213
315
 
214
316
  if (this.#persistVolume) this.#loadVolumeFromStorage();
215
317
  }
@@ -295,6 +397,48 @@ class TinyMediaPlayer extends events.EventEmitter {
295
397
  return this.#currentIndex === this.#playlist.length - 1 ? 0 : this.#playlist.length - 1;
296
398
  }
297
399
 
400
+ /**
401
+ * Handles smooth volume transitions for the active adapter.
402
+ * @param {number} targetVolume - The volume to transition to.
403
+ * @returns {Promise<void>}
404
+ */
405
+ async #fadeAdapterVolume(targetVolume) {
406
+ if (this.#fadeController) {
407
+ this.#fadeController.abort();
408
+ }
409
+
410
+ this.#fadeController = new AbortController();
411
+ const { signal } = this.#fadeController;
412
+
413
+ const adapter = this.#getActiveAdapter();
414
+ if (!adapter) return;
415
+
416
+ const startVolume = adapter.getVolume();
417
+ const startTime = performance.now();
418
+ const duration = this.#fadeVolumeSpeed;
419
+
420
+ return new Promise((resolve) => {
421
+ /** @param {number} currentTime */
422
+ const step = (currentTime) => {
423
+ if (signal.aborted) return resolve();
424
+
425
+ const elapsed = currentTime - startTime;
426
+ const progress = Math.min(elapsed / duration, 1);
427
+ const calculatedVolume = startVolume + (targetVolume - startVolume) * progress;
428
+ const clampedVolume = Math.max(0, Math.min(1, calculatedVolume));
429
+ adapter.setVolume(clampedVolume);
430
+
431
+ if (progress < 1) {
432
+ requestAnimationFrame(step);
433
+ } else {
434
+ adapter.setVolume(targetVolume);
435
+ resolve();
436
+ }
437
+ };
438
+ requestAnimationFrame(step);
439
+ });
440
+ }
441
+
298
442
  // ==========================================
299
443
  // GETTERS & STRICT SETTERS
300
444
  // ==========================================
@@ -466,6 +610,9 @@ class TinyMediaPlayer extends events.EventEmitter {
466
610
  if (value < 0 || value > 1)
467
611
  throw new RangeError('Volume must be tightly constrained between 0.0 and 1.0.');
468
612
 
613
+ // Abort any ongoing fade to ensure manual control is immediate
614
+ if (this.#fadeController) this.#fadeController.abort();
615
+
469
616
  this.#volume = value;
470
617
  this.#saveVolumeToStorage();
471
618
  this.emit('volumeChange', this.#volume);
@@ -539,21 +686,148 @@ class TinyMediaPlayer extends events.EventEmitter {
539
686
  // ADAPTER MANAGEMENT
540
687
  // ==========================================
541
688
 
689
+ get adapters() {
690
+ return Array.from(this.#adapters);
691
+ }
692
+
693
+ get adaptersSize() {
694
+ return this.#adapters.size;
695
+ }
696
+
697
+ /** @type {string[]} */
698
+ static #defaultAdapterEventNames = ['timeupdate', 'ended'];
699
+
700
+ static get defaultAdapterEventNames() {
701
+ return structuredClone(TinyMediaPlayer.#defaultAdapterEventNames);
702
+ }
703
+
704
+ /** @param {string[]} value */
705
+ static set defaultAdapterEventNames(value) {
706
+ if (!Array.isArray(value) || !value.every((v) => typeof v === 'string'))
707
+ throw new TypeError('TinyMediaPlayer.defaultAdapterEventNames must be an array of strings.');
708
+ TinyMediaPlayer.#defaultAdapterEventNames = [...value];
709
+ }
710
+
711
+ #adapterEventNames = new Set(TinyMediaPlayer.defaultAdapterEventNames);
712
+
713
+ /**
714
+ * Removes the timeupdate on adapter emitter.
715
+ * @param {BaseMediaAdapter} adapter
716
+ */
717
+ #removeHandler(adapter) {
718
+ const data = this.#adapterHandlers.get(adapter);
719
+ if (!data) return;
720
+ this.#adapterEventNames.forEach((eventName) => {
721
+ const handler = data.get(eventName);
722
+ if (handler) adapter.off(eventName, handler);
723
+ });
724
+ data.clear();
725
+ this.#adapterHandlers.delete(adapter);
726
+ }
727
+
542
728
  /**
543
729
  * Registers a new media API adapter.
544
- * @param {string} id - Unique identifier for the platform (e.g., 'youtube', 'spotify').
545
730
  * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
546
- * @throws {TypeError} If id is not a string or adapter is invalid.
731
+ * @throws {TypeError} If the adapter is invalid.
547
732
  */
548
- registerAdapter(id, adapter) {
733
+ registerAdapter(adapter) {
549
734
  checkDestroy(this.#destroyed);
550
- if (typeof id !== 'string') {
551
- throw new TypeError('Adapter ID must be a string.');
735
+ if (!(adapter instanceof index.BaseMediaAdapter)) {
736
+ throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
552
737
  }
553
- if (!(adapter instanceof BaseMediaAdapter)) {
738
+
739
+ const events = new Map();
740
+ this.#adapterHandlers.set(adapter, events);
741
+
742
+ // Use the controlled helper to ensure each event is registered
743
+ // and correctly mapped in #adapterHandlers from the start.
744
+ this.#adapterEventNames.forEach((eventName) => {
745
+ this.#attachEventToAdapter(adapter, eventName);
746
+ });
747
+
748
+ this.#adapters.add(adapter);
749
+ }
750
+
751
+ /**
752
+ * Removes a media API adapter.
753
+ * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
754
+ * @throws {TypeError} If adapter is invalid.
755
+ */
756
+ removeAdapter(adapter) {
757
+ checkDestroy(this.#destroyed);
758
+ if (!(adapter instanceof index.BaseMediaAdapter)) {
554
759
  throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
555
760
  }
556
- this.#adapters.set(id, adapter);
761
+ this.#removeHandler(adapter);
762
+ return this.#adapters.delete(adapter);
763
+ }
764
+
765
+ /**
766
+ * Destroys and removes a media API adapter.
767
+ * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
768
+ * @throws {TypeError} If adapter is invalid.
769
+ */
770
+ destroyAdapter(adapter) {
771
+ checkDestroy(this.#destroyed);
772
+ if (!(adapter instanceof index.BaseMediaAdapter)) {
773
+ throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
774
+ }
775
+ this.#removeHandler(adapter);
776
+ const result = this.#adapters.delete(adapter);
777
+ if (result) adapter.destroy();
778
+ return result;
779
+ }
780
+
781
+ /**
782
+ * If exists a media API adapter.
783
+ * @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
784
+ * @throws {TypeError} If adapter is invalid.
785
+ */
786
+ hasAdapter(adapter) {
787
+ checkDestroy(this.#destroyed);
788
+ if (!(adapter instanceof index.BaseMediaAdapter)) {
789
+ throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
790
+ }
791
+ return this.#adapters.has(adapter);
792
+ }
793
+
794
+ /**
795
+ * Destroys and removes all registered media adapters.
796
+ */
797
+ destroyAllAdapters() {
798
+ checkDestroy(this.#destroyed);
799
+ this.#adapters.forEach((adapter) => {
800
+ this.#removeHandler(adapter);
801
+ adapter.destroy();
802
+ });
803
+ this.#adapters.clear();
804
+ }
805
+
806
+ /**
807
+ * Clears all registered media adapters.
808
+ */
809
+ clearAdapters() {
810
+ checkDestroy(this.#destroyed);
811
+ this.#adapters.forEach((adapter) => this.#removeHandler(adapter));
812
+ this.#adapters.clear();
813
+ }
814
+
815
+ /**
816
+ * Helper to find the correct API wrapper for the content.
817
+ * @param {MediaContent} content - The content to check.
818
+ * @returns {BaseMediaAdapter | null} The compatible adapter, or null if empty.
819
+ * @throws {TypeError} If the content is invalid.
820
+ */
821
+ getMediaAdapter(content) {
822
+ if (!objChecker.isValidObj(content)) {
823
+ throw new TypeError('Content must be a valid object.');
824
+ }
825
+ for (const adapter of this.#adapters.values()) {
826
+ if (adapter.canHandle(content)) {
827
+ return adapter;
828
+ }
829
+ }
830
+ return null;
557
831
  }
558
832
 
559
833
  /**
@@ -564,13 +838,119 @@ class TinyMediaPlayer extends events.EventEmitter {
564
838
  #getActiveAdapter() {
565
839
  if (this.#currentIndex === -1 || this.#playlist.length === 0) return null;
566
840
  const currentContent = this.#playlist[this.#currentIndex];
841
+ const adapter = this.getMediaAdapter(currentContent);
842
+ if (adapter) return adapter;
843
+ throw new Error(`No compatible adapter found for content ID: ${currentContent.id}.`);
844
+ }
567
845
 
568
- for (const adapter of this.#adapters.values()) {
569
- if (adapter.canHandle(currentContent)) {
570
- return adapter;
846
+ // ==========================================
847
+ // EVENT MANAGEMENT (CONTROLLED)
848
+ // ==========================================
849
+
850
+ /**
851
+ * Adds a new event to be listened to on the adapters.
852
+ * @param {string} eventName - The name of the event to add.
853
+ * @throws {TypeError} If the event name is not a string.
854
+ */
855
+ addAdapterEvent(eventName) {
856
+ checkDestroy(this.#destroyed);
857
+ if (typeof eventName !== 'string') {
858
+ throw new TypeError('Event name must be a string.');
859
+ }
860
+
861
+ // If the event is already in the list, do nothing to avoid duplicates
862
+ if (this.#adapterEventNames.has(eventName)) return;
863
+
864
+ this.#adapterEventNames.add(eventName);
865
+
866
+ // Synchronize: Add the listener to all already registered adapters
867
+ this.#adapters.forEach((adapter) => {
868
+ this.#attachEventToAdapter(adapter, eventName);
869
+ });
870
+ }
871
+
872
+ /**
873
+ * Removes an event from the list of listened events.
874
+ * @param {string} eventName - The name of the event to remove.
875
+ * @throws {TypeError} If the event name is not a string.
876
+ */
877
+ removeAdapterEvent(eventName) {
878
+ checkDestroy(this.#destroyed);
879
+ if (typeof eventName !== 'string') {
880
+ throw new TypeError('Event name must be a string.');
881
+ }
882
+
883
+ if (!this.#adapterEventNames.has(eventName)) return;
884
+
885
+ this.#adapterEventNames.delete(eventName);
886
+
887
+ // Synchronize: Remove the listener from all registered adapters
888
+ this.#adapters.forEach((adapter) => {
889
+ this.#detachEventFromAdapter(adapter, eventName);
890
+ });
891
+ }
892
+
893
+ /**
894
+ * Resets the event list to the factory default state: ['timeupdate', 'ended'].
895
+ * @throws {TypeError} If the event name is not a string.
896
+ */
897
+ resetAdapterEvents() {
898
+ checkDestroy(this.#destroyed);
899
+ const defaultEvents = TinyMediaPlayer.defaultAdapterEventNames;
900
+
901
+ // Convert to Array to avoid issues when deleting items during iteration
902
+ const currentEvents = Array.from(this.#adapterEventNames);
903
+
904
+ // 1. Remove events that are not part of the factory default
905
+ for (const eventName of currentEvents) {
906
+ if (!defaultEvents.includes(eventName)) {
907
+ this.removeAdapterEvent(eventName);
571
908
  }
572
909
  }
573
- throw new Error(`No compatible adapter found for content ID: ${currentContent.id}.`);
910
+
911
+ // 2. Ensure default events exist (in case they were previously removed)
912
+ for (const defaultEvent of defaultEvents) {
913
+ if (!this.#adapterEventNames.has(defaultEvent)) {
914
+ this.addAdapterEvent(defaultEvent);
915
+ }
916
+ }
917
+ }
918
+
919
+ /**
920
+ * Private helper to attach an event to a specific adapter and save the handler.
921
+ * @param {BaseMediaAdapter} adapter - The destination adapter.
922
+ * @param {string} eventName - The event name.
923
+ */
924
+ #attachEventToAdapter(adapter, eventName) {
925
+ const handlersMap = this.#adapterHandlers.get(adapter);
926
+ if (!handlersMap) return;
927
+
928
+ // Create an encapsulated handler to maintain security (checkDestroy)
929
+ /** @type {(...args: any[]) => any} */
930
+ const handler = (...args) => {
931
+ if (!this.#destroyed) {
932
+ this.emit(eventName, ...args);
933
+ }
934
+ };
935
+
936
+ adapter.on(eventName, handler);
937
+ handlersMap.set(eventName, handler); // Mandatory synchronization with the WeakMap
938
+ }
939
+
940
+ /**
941
+ * Private helper to remove an event from a specific adapter using the saved handler.
942
+ * @param {BaseMediaAdapter} adapter - The destination adapter.
943
+ * @param {string} eventName - The event name.
944
+ */
945
+ #detachEventFromAdapter(adapter, eventName) {
946
+ const handlersMap = this.#adapterHandlers.get(adapter);
947
+ if (!handlersMap) return;
948
+
949
+ const handler = handlersMap.get(eventName);
950
+ if (handler) {
951
+ adapter.off(eventName, handler);
952
+ handlersMap.delete(eventName); // Mandatory synchronization with the WeakMap
953
+ }
574
954
  }
575
955
 
576
956
  // ==========================================
@@ -585,8 +965,9 @@ class TinyMediaPlayer extends events.EventEmitter {
585
965
  */
586
966
  addTrack(content) {
587
967
  checkDestroy(this.#destroyed);
588
- if (!content || typeof content !== 'object' || typeof content.url !== 'string')
968
+ if (!objChecker.isValidObj(content) || typeof content.url !== 'string') {
589
969
  throw new TypeError('Track content must be a valid MediaContent object containing a URL.');
970
+ }
590
971
 
591
972
  /** @type {MediaContent} */
592
973
  const newContent = { ...mediaContent.getMediaContentBase(), ...mediaContent.getMediaContentMetadata(), ...content };
@@ -604,7 +985,7 @@ class TinyMediaPlayer extends events.EventEmitter {
604
985
  /**
605
986
  * Checks if a track exists at the specified index.
606
987
  * @param {number} index - The target index.
607
- * @returns {boolean} True if the track exists, false otherwise.
988
+ * @returns {boolean} True if the track exists at the specified index.
608
989
  * @throws {TypeError} If the index is not a number.
609
990
  */
610
991
  existsTrack(index) {
@@ -620,7 +1001,7 @@ class TinyMediaPlayer extends events.EventEmitter {
620
1001
  * @param {number} index - The target index.
621
1002
  * @returns {MediaContent} The media content object.
622
1003
  * @throws {TypeError} If the index is not a number.
623
- * @throws {RangeError} If the index is out of bounds.
1004
+ * @throws {RangeError} If the index is out of the playlist bounds.
624
1005
  */
625
1006
  getTrack(index) {
626
1007
  checkDestroy(this.#destroyed);
@@ -658,7 +1039,7 @@ class TinyMediaPlayer extends events.EventEmitter {
658
1039
  this.#currentIndex = -1;
659
1040
  } else if (this.#currentIndex >= this.#playlist.length) {
660
1041
  // If we removed the last item and others exist, reset index safely
661
- this.#currentIndex = 0;
1042
+ this.#currentIndex = 0; // Reset to start if out of bounds
662
1043
  }
663
1044
  this.emit('trackChange', this.#currentIndex);
664
1045
  } else {
@@ -736,7 +1117,12 @@ class TinyMediaPlayer extends events.EventEmitter {
736
1117
  if (!adapter) return;
737
1118
 
738
1119
  // Ensure the adapter aligns with the global volume before playing
739
- adapter.setVolume(this.#volume);
1120
+ if (this.#smoothPlayPauseVolume) {
1121
+ await this.#fadeAdapterVolume(this.#volume);
1122
+ } else {
1123
+ adapter.setVolume(this.#volume);
1124
+ }
1125
+
740
1126
  await adapter.play(this.#playlist[this.#currentIndex]);
741
1127
  this.#isPlaying = true;
742
1128
  this.emit('play', this.#currentIndex);
@@ -751,6 +1137,10 @@ class TinyMediaPlayer extends events.EventEmitter {
751
1137
  const adapter = this.#getActiveAdapter();
752
1138
  if (!adapter) return;
753
1139
 
1140
+ if (this.#smoothPlayPauseVolume) {
1141
+ await this.#fadeAdapterVolume(0);
1142
+ }
1143
+
754
1144
  await adapter.pause();
755
1145
  this.#isPlaying = false;
756
1146
  this.emit('pause', this.#currentIndex);
@@ -765,6 +1155,10 @@ class TinyMediaPlayer extends events.EventEmitter {
765
1155
  const adapter = this.#getActiveAdapter();
766
1156
  if (!adapter) return;
767
1157
 
1158
+ if (this.#smoothStopVolume) {
1159
+ await this.#fadeAdapterVolume(0);
1160
+ }
1161
+
768
1162
  await adapter.stop();
769
1163
  this.#isPlaying = false;
770
1164
  this.emit('stop', this.#currentIndex);
@@ -813,6 +1207,29 @@ class TinyMediaPlayer extends events.EventEmitter {
813
1207
  checkDestroy(this.#destroyed);
814
1208
  if (this.#playlist.length === 0) return;
815
1209
 
1210
+ // UX: Repeat current track if "repeat on prev" is enabled and it's the first click
1211
+ if (this.#repeatCurrentOnPrev && !this.#prevClickedToRepeat) {
1212
+ this.#prevClickedToRepeat = true;
1213
+
1214
+ // Reset the "repeat" state after 2 seconds of inactivity to prevent glitches
1215
+ if (this.#prevClickTimer) clearTimeout(this.#prevClickTimer);
1216
+ this.#prevClickTimer = setTimeout(() => {
1217
+ this.#prevClickedToRepeat = false;
1218
+ this.#prevClickTimer = null;
1219
+ }, 2000);
1220
+
1221
+ await this.stop();
1222
+ await this.play();
1223
+ return;
1224
+ }
1225
+
1226
+ // If we reached here, it's either the second click or repeat is disabled
1227
+ if (this.#prevClickTimer) {
1228
+ clearTimeout(this.#prevClickTimer);
1229
+ this.#prevClickTimer = null;
1230
+ }
1231
+ this.#prevClickedToRepeat = false;
1232
+
816
1233
  await this.stop();
817
1234
 
818
1235
  if (this.#isRandom) {
@@ -837,9 +1254,9 @@ class TinyMediaPlayer extends events.EventEmitter {
837
1254
  /**
838
1255
  * Jumps to a specific absolute time in the timeline.
839
1256
  * @param {number} timeMs - The target time in milliseconds.
1257
+ * @returns {Promise<void>}
840
1258
  * @throws {TypeError} If timeMs is not a number.
841
1259
  * @throws {RangeError} If timeMs is negative.
842
- * @returns {Promise<void>}
843
1260
  */
844
1261
  async seek(timeMs) {
845
1262
  checkDestroy(this.#destroyed);
@@ -881,6 +1298,70 @@ class TinyMediaPlayer extends events.EventEmitter {
881
1298
  await this.seek(targetTime);
882
1299
  }
883
1300
 
1301
+ // ==========================================
1302
+ // TIME UTILITIES (DELEGATED TO ADAPTERS)
1303
+ // ==========================================
1304
+
1305
+ /**
1306
+ * Gets the current playback time.
1307
+ * @returns {number} The current time in milliseconds.
1308
+ */
1309
+ getCurrentTime() {
1310
+ checkDestroy(this.#destroyed);
1311
+ try {
1312
+ const adapter = this.#getActiveAdapter();
1313
+ return adapter ? adapter.getCurrentTime() : 0;
1314
+ } catch {
1315
+ return 0;
1316
+ }
1317
+ }
1318
+
1319
+ /**
1320
+ * Gets the total duration of the current track.
1321
+ * @returns {number} The total duration in milliseconds.
1322
+ */
1323
+ getTotalDuration() {
1324
+ checkDestroy(this.#destroyed);
1325
+ try {
1326
+ const adapter = this.#getActiveAdapter();
1327
+ return adapter ? adapter.getTotalDuration() : 0;
1328
+ } catch {
1329
+ return 0;
1330
+ }
1331
+ }
1332
+
1333
+ /**
1334
+ * Gets the remaining time until the current track ends.
1335
+ * @returns {number} The remaining time in milliseconds.
1336
+ */
1337
+ getRemainingTime() {
1338
+ checkDestroy(this.#destroyed);
1339
+ try {
1340
+ const adapter = this.#getActiveAdapter();
1341
+ return adapter ? adapter.getRemainingTime() : 0;
1342
+ } catch {
1343
+ return 0;
1344
+ }
1345
+ }
1346
+
1347
+ /**
1348
+ * Gets the percentage of the current track that has been played.
1349
+ * @returns {number} A value between 0 and 100.
1350
+ */
1351
+ getPlaybackPercentage() {
1352
+ checkDestroy(this.#destroyed);
1353
+ try {
1354
+ const adapter = this.#getActiveAdapter();
1355
+ return adapter ? adapter.getPlaybackPercentage() : 0;
1356
+ } catch {
1357
+ return 0;
1358
+ }
1359
+ }
1360
+
1361
+ // ==========================================
1362
+ // DESTROYER
1363
+ // ==========================================
1364
+
884
1365
  /**
885
1366
  * Safely destroys the TinyMediaPlayer instance.
886
1367
  * This method stops active playback, clears the internal playlist,
@@ -901,17 +1382,22 @@ class TinyMediaPlayer extends events.EventEmitter {
901
1382
  console.warn('[TinyMediaPlayer] Non-fatal error during playback termination:', error);
902
1383
  }
903
1384
 
1385
+ if (this.#prevClickTimer) clearTimeout(this.#prevClickTimer);
1386
+
904
1387
  // 2. Clear internal state variables
905
1388
  this.#playlist = [];
906
1389
  this.#currentIndex = -1;
907
1390
  this.#isPlaying = false;
1391
+ this.#prevClickedToRepeat = false;
908
1392
 
909
- // 3. Clear all registered API adapters
910
- this.#adapters.clear();
1393
+ // 3. Clears and destroys all registered API adapters
1394
+ this.destroyAllAdapters();
911
1395
 
912
1396
  // 4. Remove all event listeners inherited from EventEmitter
913
1397
  this.removeAllListeners();
1398
+ this.#adapterEventNames.clear();
914
1399
  this.#destroyed = true;
1400
+ this.emit('destroyed');
915
1401
  }
916
1402
  }
917
1403