tiny-essentials 1.26.3 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/node.js.yml +1 -1
- package/README.md +0 -1
- package/changelog/1/26/4.md +10 -0
- package/changelog/1/27/0.md +24 -0
- package/dist/v1/ColorSafeStringify.min.js +1 -1
- package/dist/v1/TinyAdvancedRaffle.min.js +1 -1
- package/dist/v1/TinyAfterScrollWatcher.min.js +1 -1
- package/dist/v1/TinyAnalogClock.min.js +1 -1
- package/dist/v1/TinyArrayComparator.min.js +1 -1
- package/dist/v1/TinyArrayPaginator.min.js +1 -1
- package/dist/v1/TinyBasicsEs.min.js +1 -1
- package/dist/v1/TinyClassManager.min.js +1 -1
- package/dist/v1/TinyClipboard.min.js +1 -1
- package/dist/v1/TinyColorConverter.min.js +1 -1
- package/dist/v1/TinyColorValidator.min.js +1 -1
- package/dist/v1/TinyCookieConsent.min.js +1 -1
- package/dist/v1/TinyDayNightCycle.min.js +1 -1
- package/dist/v1/TinyDomReadyManager.min.js +1 -1
- package/dist/v1/TinyDragDropDetector.min.js +1 -1
- package/dist/v1/TinyDragger.min.js +1 -1
- package/dist/v1/TinyElementObserver.min.js +1 -1
- package/dist/v1/TinyEssentials.min.js +1 -1
- package/dist/v1/TinyEvents.min.js +1 -1
- package/dist/v1/TinyGamepad.min.js +1 -1
- package/dist/v1/TinyHtml.min.js +1 -1
- package/dist/v1/TinyI18.min.js +1 -1
- package/dist/v1/TinyIframeEvents.min.js +1 -1
- package/dist/v1/TinyInventory.min.js +1 -1
- package/dist/v1/TinyInventoryTrader.min.js +1 -1
- package/dist/v1/TinyLevelUp.min.js +1 -1
- package/dist/v1/TinyLoadingScreen.min.js +1 -1
- package/dist/v1/TinyLocalStorage.min.js +1 -1
- package/dist/v1/TinyMaInSys.min.js +1 -1
- package/dist/v1/TinyMediaPlayer.min.js +1 -0
- package/dist/v1/TinyNeedBar.min.js +1 -1
- package/dist/v1/TinyNewWinEvents.min.js +1 -1
- package/dist/v1/TinyNotifications.min.js +1 -1
- package/dist/v1/TinyNotifyCenter.min.js +1 -1
- package/dist/v1/TinyPromiseQueue.min.js +1 -1
- package/dist/v1/TinyRadioFm.min.js +1 -0
- package/dist/v1/TinyRateLimiter.min.js +1 -1
- package/dist/v1/TinySimpleDice.min.js +1 -1
- package/dist/v1/TinySmartScroller.min.js +1 -1
- package/dist/v1/TinyTextDiffer.min.js +1 -1
- package/dist/v1/TinyTextRangeEditor.min.js +1 -1
- package/dist/v1/TinyTextarea.min.js +1 -1
- package/dist/v1/TinyTimeout.min.js +1 -1
- package/dist/v1/TinyToastNotify.min.js +1 -1
- package/dist/v1/TinyUploadClicker.min.js +1 -1
- package/dist/v1/UltraRandomMsgGen.min.js +1 -1
- package/dist/v1/basics/index.cjs +5 -0
- package/dist/v1/basics/index.d.mts +5 -1
- package/dist/v1/basics/index.mjs +2 -1
- package/dist/v1/basics/mediaContent.cjs +644 -0
- package/dist/v1/basics/mediaContent.d.mts +296 -0
- package/dist/v1/basics/mediaContent.mjs +557 -0
- package/dist/v1/build/TinyMediaPlayer.cjs +7 -0
- package/dist/v1/build/TinyMediaPlayer.d.mts +3 -0
- package/dist/v1/build/TinyMediaPlayer.mjs +2 -0
- package/dist/v1/build/TinyRadioFm.cjs +7 -0
- package/dist/v1/build/TinyRadioFm.d.mts +3 -0
- package/dist/v1/build/TinyRadioFm.mjs +2 -0
- package/dist/v1/index.cjs +9 -0
- package/dist/v1/index.d.mts +7 -1
- package/dist/v1/index.mjs +5 -2
- package/dist/v1/libs/ColorSafeStringify.d.mts +1 -1
- package/dist/v1/libs/TinyAfterScrollWatcher.cjs +16 -3
- package/dist/v1/libs/TinyAfterScrollWatcher.d.mts +7 -3
- package/dist/v1/libs/TinyAfterScrollWatcher.mjs +13 -3
- package/dist/v1/libs/TinyArrayComparator.cjs +14 -11
- package/dist/v1/libs/TinyArrayComparator.d.mts +22 -19
- package/dist/v1/libs/TinyArrayComparator.mjs +14 -11
- package/dist/v1/libs/TinyArrayPaginator.cjs +23 -16
- package/dist/v1/libs/TinyArrayPaginator.d.mts +57 -30
- package/dist/v1/libs/TinyArrayPaginator.mjs +22 -16
- package/dist/v1/libs/TinyClassManager/TinyPluginInliner.cjs +265 -0
- package/dist/v1/libs/TinyClassManager/TinyPluginInliner.d.mts +92 -0
- package/dist/v1/libs/TinyClassManager/TinyPluginInliner.mjs +223 -0
- package/dist/v1/libs/TinyClassManager.cjs +124 -23
- package/dist/v1/libs/TinyClassManager.d.mts +33 -13
- package/dist/v1/libs/TinyClassManager.mjs +108 -23
- package/dist/v1/libs/TinyColorValidator.d.mts +16 -16
- package/dist/v1/libs/TinyDragDropDetector.cjs +9 -34
- package/dist/v1/libs/TinyDragDropDetector.d.mts +7 -21
- package/dist/v1/libs/TinyDragDropDetector.mjs +9 -24
- package/dist/v1/libs/TinyDragger.cjs +5 -5
- package/dist/v1/libs/TinyDragger.d.mts +4 -3
- package/dist/v1/libs/TinyDragger.mjs +5 -5
- package/dist/v1/libs/TinyElementObserver.cjs +6 -21
- package/dist/v1/libs/TinyElementObserver.d.mts +6 -15
- package/dist/v1/libs/TinyElementObserver.mjs +6 -20
- package/dist/v1/libs/TinyEvents.cjs +7 -0
- package/dist/v1/libs/TinyEvents.d.mts +4 -0
- package/dist/v1/libs/TinyEvents.mjs +6 -0
- package/dist/v1/libs/TinyGamepad.cjs +4 -6
- package/dist/v1/libs/TinyGamepad.d.mts +1 -1
- package/dist/v1/libs/TinyHtml/TinyHtmlIcon.d.mts +1 -1
- package/dist/v1/libs/TinyHtml/TinyHtmlTemplate.d.mts +1 -1
- package/dist/v1/libs/TinyHtml/TinyHtmlTextarea.d.mts +1 -1
- package/dist/v1/libs/TinyHtml/index.d.mts +1 -1
- package/dist/v1/libs/TinyHtml.cjs +72 -98
- package/dist/v1/libs/TinyHtml.d.mts +15 -32
- package/dist/v1/libs/TinyHtml.mjs +53 -77
- package/dist/v1/libs/TinyInventory.d.mts +1 -1
- package/dist/v1/libs/TinyMediaPlayer.cjs +844 -0
- package/dist/v1/libs/TinyMediaPlayer.d.mts +306 -0
- package/dist/v1/libs/TinyMediaPlayer.mjs +755 -0
- package/dist/v1/libs/TinyRadioFm.cjs +1378 -0
- package/dist/v1/libs/TinyRadioFm.d.mts +554 -0
- package/dist/v1/libs/TinyRadioFm.mjs +1137 -0
- package/dist/v1/libs/TinySmartScroller.cjs +39 -26
- package/dist/v1/libs/TinySmartScroller.d.mts +16 -62
- package/dist/v1/libs/TinySmartScroller.mjs +35 -26
- package/dist/v1/libs/UltraRandomMsgGen.d.mts +7 -7
- package/dist/v1/libs/examples/TinyClassManager.cjs +43 -41
- package/dist/v1/libs/examples/TinyClassManager.mjs +37 -37
- package/docs/v1/README.md +6 -0
- package/docs/v1/basics/mediaContent.md +131 -0
- package/docs/v1/libs/TinyClassManager/TinyPluginInliner.md +107 -0
- package/docs/v1/libs/TinyClassManager.md +69 -70
- package/docs/v1/libs/TinyDragDropDetector.md +5 -9
- package/docs/v1/libs/TinyElementObserver.md +3 -6
- package/docs/v1/libs/TinyEvents.md +1 -1
- package/docs/v1/libs/TinyHtml.md +0 -29
- package/docs/v1/libs/TinyMediaPlayer.md +100 -0
- package/docs/v1/libs/TinyRadioFm.md +141 -0
- package/docs/v1/libs/TinySmartScroller.md +3 -3
- package/package.json +40 -22
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var TinyEvents = require('./TinyEvents.cjs');
|
|
4
|
+
var mediaContent = require('../basics/mediaContent.cjs');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import('../basics/mediaContent.mjs').MediaContent} MediaContent
|
|
8
|
+
* @typedef {import('../basics/mediaContent.mjs').MediaContentBase} MediaContentBase
|
|
9
|
+
* @typedef {import('../basics/mediaContent.mjs').MediaContentMetadata} MediaContentMetadata
|
|
10
|
+
* @typedef {import('../basics/mediaContent.mjs').ParseMediaContentMetadata} ParseMediaContentMetadata
|
|
11
|
+
* @typedef {import('../basics/mediaContent.mjs').LoadingMediaProgress} LoadingMediaProgress
|
|
12
|
+
* @typedef {import('../basics/mediaContent.mjs').MediaLoadingErrorData} MediaLoadingErrorData
|
|
13
|
+
* @typedef {import('../basics/mediaContent.mjs').UnknownArtistGetter} UnknownArtistGetter
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {'NONE' | 'TRACK' | 'PLAYLIST'} LoopModeType
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration options for initializing the UniversalMediaPlayer.
|
|
22
|
+
* @typedef {Object} TinyMediaPlayerOptions
|
|
23
|
+
* @property {boolean} [persistVolume=false] - Whether to automatically save the volume in localStorage.
|
|
24
|
+
* @property {string} [volumeStorageKey='universal_media_player_volume'] - The specific key name used for localStorage cache.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Interface definition for a Media Provider Adapter.
|
|
29
|
+
* All specific API wrappers must extend and implement this class.
|
|
30
|
+
* @abstract
|
|
31
|
+
*/
|
|
32
|
+
class BaseMediaAdapter {
|
|
33
|
+
constructor() {
|
|
34
|
+
if (new.target === BaseMediaAdapter) {
|
|
35
|
+
throw new Error('BaseMediaAdapter is an abstract class and cannot be instantiated directly.');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Determines if this adapter can play the provided content.
|
|
41
|
+
* @param {MediaContent} content - The media content to evaluate.
|
|
42
|
+
* @returns {boolean} True if the adapter can handle the content, false otherwise.
|
|
43
|
+
*/
|
|
44
|
+
canHandle(content) {
|
|
45
|
+
throw new Error('Method "canHandle" must be implemented by the subclass.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Starts or resumes playback of the provided content.
|
|
50
|
+
* @param {MediaContent} content - The media content to play.
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async play(content) {
|
|
54
|
+
throw new Error('Method "play" must be implemented by the subclass.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pauses the current playback.
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async pause() {
|
|
62
|
+
throw new Error('Method "pause" must be implemented by the subclass.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stops the playback completely and resets the internal platform state.
|
|
67
|
+
* @returns {Promise<void>}
|
|
68
|
+
*/
|
|
69
|
+
async stop() {
|
|
70
|
+
throw new Error('Method "stop" must be implemented by the subclass.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Seeks to a specific time in the media timeline.
|
|
75
|
+
* @param {number} timeMs - The target time in milliseconds.
|
|
76
|
+
* @returns {Promise<void>}
|
|
77
|
+
*/
|
|
78
|
+
async seek(timeMs) {
|
|
79
|
+
throw new Error('Method "seek" must be implemented by the subclass.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Retrieves the current playback time from the underlying API.
|
|
84
|
+
* @returns {number} The current time in milliseconds.
|
|
85
|
+
*/
|
|
86
|
+
getCurrentTime() {
|
|
87
|
+
throw new Error('Method "getCurrentTime" must be implemented by the subclass.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sets the playback volume for the underlying API.
|
|
92
|
+
* @param {number} volume - The volume level from 0.0 to 1.0.
|
|
93
|
+
* @returns {void}
|
|
94
|
+
*/
|
|
95
|
+
setVolume(volume) {
|
|
96
|
+
throw new Error('Method "setVolume" must be implemented by the subclass.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Represents a search match containing the media object and its exact playlist index.
|
|
102
|
+
* @typedef {Object} SearchResult
|
|
103
|
+
* @property {MediaContent} track - The matched media content.
|
|
104
|
+
* @property {number} index - The current index of the track in the playlist.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A universal media player manager capable of orchestrating multiple API adapters.
|
|
109
|
+
*/
|
|
110
|
+
class TinyMediaPlayer extends TinyEvents {
|
|
111
|
+
static BaseMediaAdapter = BaseMediaAdapter;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @type {UnknownArtistGetter}
|
|
115
|
+
* The default identifier or function used when an artist cannot be determined.
|
|
116
|
+
*/
|
|
117
|
+
static #unknownArtist = 'Unknown Artist';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Gets the current value used to represent unknown artists.
|
|
121
|
+
* @returns {UnknownArtistGetter}
|
|
122
|
+
*/
|
|
123
|
+
static get unknownArtist() {
|
|
124
|
+
return TinyMediaPlayer.#unknownArtist;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sets the value used to represent unknown artists.
|
|
129
|
+
* @param {UnknownArtistGetter} value - A string or a function that returns a string.
|
|
130
|
+
* @throws {TypeError} If the value is neither a string nor a function.
|
|
131
|
+
*/
|
|
132
|
+
static set unknownArtist(value) {
|
|
133
|
+
if (typeof value !== 'string' && typeof value !== 'function')
|
|
134
|
+
throw new TypeError('unknownArtist must have an string or function.');
|
|
135
|
+
TinyMediaPlayer.#unknownArtist = value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A Static Factory Method that prepares a MediaContent object by
|
|
140
|
+
* extracting metadata from an audio source.
|
|
141
|
+
*
|
|
142
|
+
* @param {string | HTMLMediaElement} source - A URL string or an existing Audio object.
|
|
143
|
+
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata that overrides automatic extraction.
|
|
144
|
+
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata that overrides automatic extraction.
|
|
145
|
+
* @param {ParseMediaContentMetadata} [parseFile] - Private helper to interface with parseFile.
|
|
146
|
+
* @param {Object} [callbacks={}] - Callbacks for monitoring the loading process.
|
|
147
|
+
* @param {(progress: LoadingMediaProgress) => void} [callbacks.onProgress] - Callback triggered on stage changes.
|
|
148
|
+
* @param {(error: MediaLoadingErrorData) => void} [callbacks.onError] - Callback triggered when a non-fatal or fatal error occurs.
|
|
149
|
+
* @returns {Promise<MediaContent>} A promise that resolves to a valid MediaContent object.
|
|
150
|
+
* @throws {MediaLoadingError} If the preparation process fails at any stage.
|
|
151
|
+
*/
|
|
152
|
+
static async parseContent(source, defaultMetadata, metadata, parseFile, callbacks) {
|
|
153
|
+
return mediaContent.parseMediaMetadata(
|
|
154
|
+
source,
|
|
155
|
+
defaultMetadata,
|
|
156
|
+
metadata,
|
|
157
|
+
parseFile,
|
|
158
|
+
callbacks,
|
|
159
|
+
TinyMediaPlayer.#unknownArtist,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @type {Map<string, BaseMediaAdapter>} */
|
|
164
|
+
#adapters = new Map();
|
|
165
|
+
|
|
166
|
+
/** @type {MediaContent[]} */
|
|
167
|
+
#playlist = [];
|
|
168
|
+
|
|
169
|
+
/** @type {number} */
|
|
170
|
+
#currentIndex = -1;
|
|
171
|
+
|
|
172
|
+
/** @type {LoopModeType} */
|
|
173
|
+
#loopMode = 'NONE';
|
|
174
|
+
|
|
175
|
+
/** @type {boolean} */
|
|
176
|
+
#isRandom = false;
|
|
177
|
+
|
|
178
|
+
/** @type {boolean} */
|
|
179
|
+
#isPlaying = false;
|
|
180
|
+
|
|
181
|
+
/** @type {number} */
|
|
182
|
+
#volume = 1.0;
|
|
183
|
+
|
|
184
|
+
/** @type {boolean} */
|
|
185
|
+
#persistVolume;
|
|
186
|
+
|
|
187
|
+
/** @type {string} */
|
|
188
|
+
#volumeStorageKey;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {TinyMediaPlayerOptions} [options={}] - Configuration parameters for the player.
|
|
192
|
+
*/
|
|
193
|
+
constructor(options = {}) {
|
|
194
|
+
super();
|
|
195
|
+
// Volume configuration
|
|
196
|
+
this.#persistVolume = Boolean(options.persistVolume);
|
|
197
|
+
this.#volumeStorageKey =
|
|
198
|
+
typeof options.volumeStorageKey === 'string'
|
|
199
|
+
? options.volumeStorageKey
|
|
200
|
+
: 'tiny_media_player_volume';
|
|
201
|
+
|
|
202
|
+
if (this.#persistVolume) this.#loadVolumeFromStorage();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ==========================================
|
|
206
|
+
// STORAGE HELPERS
|
|
207
|
+
// ==========================================
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Attempts to load the volume state from localStorage safely.
|
|
211
|
+
*/
|
|
212
|
+
#loadVolumeFromStorage() {
|
|
213
|
+
try {
|
|
214
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
215
|
+
const storedValue = window.localStorage.getItem(this.#volumeStorageKey);
|
|
216
|
+
if (storedValue !== null) {
|
|
217
|
+
const parsedVolume = parseFloat(storedValue);
|
|
218
|
+
if (!isNaN(parsedVolume) && parsedVolume >= 0 && parsedVolume <= 1) {
|
|
219
|
+
this.#volume = parsedVolume;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
console.warn('[TinyMediaPlayer] Failed to read volume from localStorage.', error);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Attempts to save the current volume state to localStorage safely.
|
|
230
|
+
*/
|
|
231
|
+
#saveVolumeToStorage() {
|
|
232
|
+
try {
|
|
233
|
+
if (this.#persistVolume && typeof window !== 'undefined' && window.localStorage) {
|
|
234
|
+
window.localStorage.setItem(this.#volumeStorageKey, this.#volume.toString());
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.warn('[TinyMediaPlayer] Failed to save volume to localStorage.', error);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ==========================================
|
|
242
|
+
// INTERNAL HELPERS
|
|
243
|
+
// ==========================================
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Calculates a random index based on the weighted probability of each track,
|
|
247
|
+
* strictly excluding the currently active track to prevent immediate repetition.
|
|
248
|
+
* @returns {number} The selected index.
|
|
249
|
+
* @private
|
|
250
|
+
*/
|
|
251
|
+
_getWeightedRandomIndex() {
|
|
252
|
+
if (this.#playlist.length === 0) return -1;
|
|
253
|
+
if (this.#playlist.length === 1) return 0;
|
|
254
|
+
|
|
255
|
+
let totalWeight = 0;
|
|
256
|
+
|
|
257
|
+
// Calculate total sum of all weights, skipping the current track
|
|
258
|
+
for (let i = 0; i < this.#playlist.length; i++) {
|
|
259
|
+
if (i === this.#currentIndex) continue;
|
|
260
|
+
|
|
261
|
+
const track = this.#playlist[i];
|
|
262
|
+
const weight = typeof track.weight === 'number' && track.weight > 0 ? track.weight : 1;
|
|
263
|
+
totalWeight += weight;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Pick a random number between 0 and totalWeight
|
|
267
|
+
let randomThreshold = Math.random() * totalWeight;
|
|
268
|
+
|
|
269
|
+
// Find the track that corresponds to this threshold, skipping the current track
|
|
270
|
+
for (let i = 0; i < this.#playlist.length; i++) {
|
|
271
|
+
if (i === this.#currentIndex) continue;
|
|
272
|
+
|
|
273
|
+
const track = this.#playlist[i];
|
|
274
|
+
const weight = typeof track.weight === 'number' && track.weight > 0 ? track.weight : 1;
|
|
275
|
+
|
|
276
|
+
if (randomThreshold < weight) {
|
|
277
|
+
return i;
|
|
278
|
+
}
|
|
279
|
+
randomThreshold -= weight;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fallback in case of floating point inaccuracies
|
|
283
|
+
return this.#currentIndex === this.#playlist.length - 1 ? 0 : this.#playlist.length - 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ==========================================
|
|
287
|
+
// GETTERS & STRICT SETTERS
|
|
288
|
+
// ==========================================
|
|
289
|
+
|
|
290
|
+
/** @returns {MediaContent[]} A shallow copy of the current playlist. */
|
|
291
|
+
get playlist() {
|
|
292
|
+
return [...this.#playlist];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {MediaContent[]} value - The new playlist array.
|
|
297
|
+
* @throws {TypeError} If the value is not an array.
|
|
298
|
+
*/
|
|
299
|
+
set playlist(value) {
|
|
300
|
+
if (!Array.isArray(value)) {
|
|
301
|
+
throw new TypeError('Playlist must be an array of MediaContent objects.');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
value.forEach((val) => mediaContent.valMediaContentMetadata(val));
|
|
305
|
+
const wasPlaying = this.#isPlaying;
|
|
306
|
+
let oldAdapter = null;
|
|
307
|
+
|
|
308
|
+
// Capture the active adapter before making structural changes
|
|
309
|
+
if (this.#currentIndex !== -1 && this.#playlist.length > 0) {
|
|
310
|
+
try {
|
|
311
|
+
oldAdapter = this.#getActiveAdapter();
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// Ignore if no compatible adapter was found for the old content
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.#playlist = value;
|
|
318
|
+
this.emit('playlistUpdate', this.playlist);
|
|
319
|
+
|
|
320
|
+
// Validation to correct currentIndex ensuring it aligns with the new playlist limits
|
|
321
|
+
if (this.#playlist.length === 0) {
|
|
322
|
+
this.#currentIndex = -1;
|
|
323
|
+
} else if (this.#currentIndex >= this.#playlist.length) {
|
|
324
|
+
this.#currentIndex = 0; // Reset to start if out of bounds
|
|
325
|
+
} else if (this.#currentIndex === -1) {
|
|
326
|
+
this.#currentIndex = 0; // Auto-select first item if previously empty
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.emit('trackChange', this.#currentIndex);
|
|
330
|
+
|
|
331
|
+
// Immediately adapt to the new state if it was playing
|
|
332
|
+
if (wasPlaying) {
|
|
333
|
+
(async () => {
|
|
334
|
+
try {
|
|
335
|
+
if (oldAdapter) await oldAdapter.stop();
|
|
336
|
+
if (this.#currentIndex !== -1) await this.play();
|
|
337
|
+
} catch (error) {
|
|
338
|
+
console.warn('[TinyMediaPlayer] Background transition error on playlist update:', error);
|
|
339
|
+
}
|
|
340
|
+
})();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** @returns {number} The current active index in the playlist. */
|
|
345
|
+
get currentIndex() {
|
|
346
|
+
return this.#currentIndex;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* @param {number} value - The index to set as current.
|
|
351
|
+
* @throws {TypeError} If the value is not a number.
|
|
352
|
+
* @throws {RangeError} If the index is out of the playlist bounds (unless -1 for empty).
|
|
353
|
+
*/
|
|
354
|
+
set currentIndex(value) {
|
|
355
|
+
if (typeof value !== 'number') {
|
|
356
|
+
throw new TypeError('Current index must be a number.');
|
|
357
|
+
}
|
|
358
|
+
if (value < -1 || (value >= this.#playlist.length && this.#playlist.length > 0)) {
|
|
359
|
+
throw new RangeError(`Index ${value} is out of bounds for the current playlist.`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (this.#currentIndex === value) return; // Optimization: do nothing if index is the same
|
|
363
|
+
|
|
364
|
+
const wasPlaying = this.#isPlaying;
|
|
365
|
+
let oldAdapter = null;
|
|
366
|
+
|
|
367
|
+
if (this.#currentIndex !== -1 && this.#playlist.length > 0) {
|
|
368
|
+
try {
|
|
369
|
+
oldAdapter = this.#getActiveAdapter();
|
|
370
|
+
} catch (error) {
|
|
371
|
+
// Ignore
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.#currentIndex = value;
|
|
376
|
+
this.emit('trackChange', this.#currentIndex);
|
|
377
|
+
|
|
378
|
+
// Automatically correct the audio output to match the new current index
|
|
379
|
+
if (wasPlaying) {
|
|
380
|
+
(async () => {
|
|
381
|
+
try {
|
|
382
|
+
if (oldAdapter) await oldAdapter.stop();
|
|
383
|
+
if (this.#currentIndex !== -1) await this.play();
|
|
384
|
+
} catch (error) {
|
|
385
|
+
console.warn('[TinyMediaPlayer] Background transition error on index update:', error);
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** @returns {LoopModeType} The current loop configuration. */
|
|
392
|
+
get loopMode() {
|
|
393
|
+
return this.#loopMode;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* @param {LoopModeType} value - The desired loop mode.
|
|
398
|
+
* @throws {TypeError} If the value is not a valid LoopModeType.
|
|
399
|
+
*/
|
|
400
|
+
set loopMode(value) {
|
|
401
|
+
const validModes = ['NONE', 'TRACK', 'PLAYLIST'];
|
|
402
|
+
if (!validModes.includes(value)) {
|
|
403
|
+
throw new TypeError(`Loop mode must be one of: ${validModes.join(', ')}.`);
|
|
404
|
+
}
|
|
405
|
+
this.#loopMode = value;
|
|
406
|
+
this.emit('loopModeChange', this.#loopMode);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** @returns {boolean} The current random mode status. */
|
|
410
|
+
get isRandom() {
|
|
411
|
+
return this.#isRandom;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {boolean} value - True to enable random playback, false otherwise.
|
|
416
|
+
* @throws {TypeError} If the value is not a boolean.
|
|
417
|
+
*/
|
|
418
|
+
set isRandom(value) {
|
|
419
|
+
if (typeof value !== 'boolean') {
|
|
420
|
+
throw new TypeError('Random mode state must be a boolean.');
|
|
421
|
+
}
|
|
422
|
+
this.#isRandom = value;
|
|
423
|
+
this.emit('randomModeChange', this.#isRandom);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @returns {boolean} True if media is actively playing. */
|
|
427
|
+
get isPlaying() {
|
|
428
|
+
return this.#isPlaying;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** @returns {number} The current volume level (0.0 to 1.0). */
|
|
432
|
+
get volume() {
|
|
433
|
+
return this.#volume;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @param {number} value - The volume level to set.
|
|
438
|
+
* @throws {TypeError} If the value is not a number.
|
|
439
|
+
* @throws {RangeError} If the value is outside the 0.0 to 1.0 range.
|
|
440
|
+
*/
|
|
441
|
+
set volume(value) {
|
|
442
|
+
if (typeof value !== 'number') throw new TypeError('Volume must be a number.');
|
|
443
|
+
if (value < 0 || value > 1)
|
|
444
|
+
throw new RangeError('Volume must be tightly constrained between 0.0 and 1.0.');
|
|
445
|
+
|
|
446
|
+
this.#volume = value;
|
|
447
|
+
this.#saveVolumeToStorage();
|
|
448
|
+
this.emit('volumeChange', this.#volume);
|
|
449
|
+
|
|
450
|
+
// Immediately apply the new volume if a track is active
|
|
451
|
+
if (this.#currentIndex !== -1 && this.#playlist.length > 0) {
|
|
452
|
+
try {
|
|
453
|
+
const adapter = this.#getActiveAdapter();
|
|
454
|
+
if (adapter) adapter.setVolume(this.#volume);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
// Fails silently if just adjusting volume on invalid content
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** @returns {boolean} Whether the volume cache is currently enabled. */
|
|
462
|
+
get persistVolume() {
|
|
463
|
+
return this.#persistVolume;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* @param {boolean} value - True to save volume dynamically to localStorage.
|
|
468
|
+
* @throws {TypeError} If the value is not a boolean.
|
|
469
|
+
*/
|
|
470
|
+
set persistVolume(value) {
|
|
471
|
+
if (typeof value !== 'boolean') {
|
|
472
|
+
throw new TypeError('Persist volume parameter must be a boolean.');
|
|
473
|
+
}
|
|
474
|
+
this.#persistVolume = value;
|
|
475
|
+
if (value) {
|
|
476
|
+
this.#saveVolumeToStorage();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** @returns {string} The localStorage key used for volume caching. */
|
|
481
|
+
get volumeStorageKey() {
|
|
482
|
+
return this.#volumeStorageKey;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* @param {string} value - The custom storage key string.
|
|
487
|
+
* @throws {TypeError} If the value is not a string or is empty.
|
|
488
|
+
*/
|
|
489
|
+
set volumeStorageKey(value) {
|
|
490
|
+
if (typeof value !== 'string') {
|
|
491
|
+
throw new TypeError('Volume storage key must be a string.');
|
|
492
|
+
}
|
|
493
|
+
if (value.trim() === '') {
|
|
494
|
+
throw new TypeError('Volume storage key cannot be an empty string.');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const previousKey = this.#volumeStorageKey;
|
|
498
|
+
this.#volumeStorageKey = value;
|
|
499
|
+
|
|
500
|
+
// Migrate the cache to the new key if persistence is active
|
|
501
|
+
try {
|
|
502
|
+
if (this.#persistVolume && typeof window !== 'undefined' && window.localStorage) {
|
|
503
|
+
window.localStorage.removeItem(previousKey);
|
|
504
|
+
this.#saveVolumeToStorage();
|
|
505
|
+
}
|
|
506
|
+
} catch (error) {
|
|
507
|
+
console.warn('[TinyMediaPlayer] Failed to migrate volume storage key.', error);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ==========================================
|
|
512
|
+
// ADAPTER MANAGEMENT
|
|
513
|
+
// ==========================================
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Registers a new media API adapter.
|
|
517
|
+
* @param {string} id - Unique identifier for the platform (e.g., 'youtube', 'spotify').
|
|
518
|
+
* @param {BaseMediaAdapter} adapter - An instance extending BaseMediaAdapter.
|
|
519
|
+
* @throws {TypeError} If id is not a string or adapter is invalid.
|
|
520
|
+
*/
|
|
521
|
+
registerAdapter(id, adapter) {
|
|
522
|
+
if (typeof id !== 'string') {
|
|
523
|
+
throw new TypeError('Adapter ID must be a string.');
|
|
524
|
+
}
|
|
525
|
+
if (!(adapter instanceof BaseMediaAdapter)) {
|
|
526
|
+
throw new TypeError('Adapter must be an instance of BaseMediaAdapter.');
|
|
527
|
+
}
|
|
528
|
+
this.#adapters.set(id, adapter);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Internal helper to find the correct API wrapper for the current content.
|
|
533
|
+
* @returns {BaseMediaAdapter | null} The compatible adapter, or null if empty.
|
|
534
|
+
* @throws {Error} If no compatible adapter is found for the content.
|
|
535
|
+
*/
|
|
536
|
+
#getActiveAdapter() {
|
|
537
|
+
if (this.#currentIndex === -1 || this.#playlist.length === 0) return null;
|
|
538
|
+
const currentContent = this.#playlist[this.#currentIndex];
|
|
539
|
+
|
|
540
|
+
for (const adapter of this.#adapters.values()) {
|
|
541
|
+
if (adapter.canHandle(currentContent)) {
|
|
542
|
+
return adapter;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
throw new Error(`No compatible adapter found for content ID: ${currentContent.id}.`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ==========================================
|
|
549
|
+
// PLAYLIST MANAGEMENT
|
|
550
|
+
// ==========================================
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Adds a new item to the end of the playlist.
|
|
554
|
+
* @param {MediaContent} content - The structured media content object.
|
|
555
|
+
* @returns {number} The new length of the playlist.
|
|
556
|
+
* @throws {TypeError} If content is invalid or missing required base properties.
|
|
557
|
+
*/
|
|
558
|
+
addTrack(content) {
|
|
559
|
+
if (!content || typeof content !== 'object' || typeof content.url !== 'string')
|
|
560
|
+
throw new TypeError('Track content must be a valid MediaContent object containing a URL.');
|
|
561
|
+
|
|
562
|
+
/** @type {MediaContent} */
|
|
563
|
+
const newContent = { ...mediaContent.getMediaContentBase(), ...mediaContent.getMediaContentMetadata(), ...content };
|
|
564
|
+
mediaContent.valMediaContentMetadata(newContent);
|
|
565
|
+
|
|
566
|
+
const newLength = this.#playlist.push(newContent);
|
|
567
|
+
if (this.#currentIndex === -1) {
|
|
568
|
+
this.#currentIndex = 0;
|
|
569
|
+
this.emit('trackChange', this.#currentIndex);
|
|
570
|
+
}
|
|
571
|
+
this.emit('playlistUpdate', this.playlist);
|
|
572
|
+
return newLength;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Checks if a track exists at the specified index.
|
|
577
|
+
* @param {number} index - The target index.
|
|
578
|
+
* @returns {boolean} True if the track exists, false otherwise.
|
|
579
|
+
* @throws {TypeError} If the index is not a number.
|
|
580
|
+
*/
|
|
581
|
+
existsTrack(index) {
|
|
582
|
+
if (typeof index !== 'number') {
|
|
583
|
+
throw new TypeError('Index must be a number.');
|
|
584
|
+
}
|
|
585
|
+
return index >= 0 && index < this.#playlist.length;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Retrieves the track at the specified index.
|
|
590
|
+
* @param {number} index - The target index.
|
|
591
|
+
* @returns {MediaContent} The media content object.
|
|
592
|
+
* @throws {TypeError} If the index is not a number.
|
|
593
|
+
* @throws {RangeError} If the index is out of bounds.
|
|
594
|
+
*/
|
|
595
|
+
getTrack(index) {
|
|
596
|
+
if (typeof index !== 'number') {
|
|
597
|
+
throw new TypeError('Index must be a number.');
|
|
598
|
+
}
|
|
599
|
+
if (!this.existsTrack(index)) {
|
|
600
|
+
throw new RangeError(`Index ${index} is out of bounds for the current playlist.`);
|
|
601
|
+
}
|
|
602
|
+
return this.#playlist[index];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Removes the track at the specified index and manages playback state accordingly.
|
|
607
|
+
* @param {number} index - The index of the track to remove.
|
|
608
|
+
* @returns {Promise<void>}
|
|
609
|
+
* @throws {TypeError} If the index is not a number.
|
|
610
|
+
* @throws {RangeError} If the index is out of bounds.
|
|
611
|
+
*/
|
|
612
|
+
async removeTrack(index) {
|
|
613
|
+
if (typeof index !== 'number') {
|
|
614
|
+
throw new TypeError('Index must be a number.');
|
|
615
|
+
}
|
|
616
|
+
if (!this.existsTrack(index)) {
|
|
617
|
+
throw new RangeError(`Index ${index} is out of bounds for the current playlist.`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (index === this.#currentIndex) {
|
|
621
|
+
// Stop the current track if it is the one being removed
|
|
622
|
+
await this.stop();
|
|
623
|
+
this.#playlist.splice(index, 1);
|
|
624
|
+
|
|
625
|
+
if (this.#playlist.length === 0) {
|
|
626
|
+
this.#currentIndex = -1;
|
|
627
|
+
} else if (this.#currentIndex >= this.#playlist.length) {
|
|
628
|
+
// If we removed the last item and others exist, reset index safely
|
|
629
|
+
this.#currentIndex = 0;
|
|
630
|
+
}
|
|
631
|
+
this.emit('trackChange', this.#currentIndex);
|
|
632
|
+
} else {
|
|
633
|
+
this.#playlist.splice(index, 1);
|
|
634
|
+
// Correct the current index offset if a track before it was removed
|
|
635
|
+
if (index < this.#currentIndex) {
|
|
636
|
+
this.#currentIndex -= 1;
|
|
637
|
+
// Current track didn't change, just its index shifted
|
|
638
|
+
this.emit('trackChange', this.#currentIndex);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
this.emit('playlistUpdate', this.playlist);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Searches the playlist for tracks matching a string query or a custom evaluation function.
|
|
646
|
+
* @param {string | function(MediaContent): boolean} query - The search string (checked against title, artist, album) or a callback returning a boolean.
|
|
647
|
+
* @returns {SearchResult[]} An array containing the matched tracks and their corresponding indices.
|
|
648
|
+
* @throws {TypeError} If the query is neither a string nor a function.
|
|
649
|
+
*/
|
|
650
|
+
searchTrack(query) {
|
|
651
|
+
if (typeof query !== 'string' && typeof query !== 'function') {
|
|
652
|
+
throw new TypeError('Search query must be a string or a boolean evaluation function.');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const results = [];
|
|
656
|
+
|
|
657
|
+
for (let i = 0; i < this.#playlist.length; i++) {
|
|
658
|
+
const track = this.#playlist[i];
|
|
659
|
+
let isMatch = false;
|
|
660
|
+
|
|
661
|
+
if (typeof query === 'function') {
|
|
662
|
+
isMatch = Boolean(query(track));
|
|
663
|
+
} else {
|
|
664
|
+
const lowerQuery = query.toLowerCase();
|
|
665
|
+
const titleMatch = track.title && track.title.toLowerCase().includes(lowerQuery);
|
|
666
|
+
const artistMatch = track.artist && track.artist.toLowerCase().includes(lowerQuery);
|
|
667
|
+
const albumMatch = track.album && track.album.toLowerCase().includes(lowerQuery);
|
|
668
|
+
|
|
669
|
+
isMatch = Boolean(titleMatch || artistMatch || albumMatch);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (isMatch) {
|
|
673
|
+
results.push({ track, index: i });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
return results;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Clears the entire playlist and stops playback.
|
|
682
|
+
*/
|
|
683
|
+
async clearPlaylist() {
|
|
684
|
+
await this.stop();
|
|
685
|
+
this.#playlist = [];
|
|
686
|
+
this.#currentIndex = -1;
|
|
687
|
+
this.emit('playlistUpdate', this.playlist);
|
|
688
|
+
this.emit('trackChange', this.#currentIndex);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ==========================================
|
|
692
|
+
// PLAYBACK CONTROLS
|
|
693
|
+
// ==========================================
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Starts or resumes playback of the current track.
|
|
697
|
+
* @returns {Promise<void>}
|
|
698
|
+
*/
|
|
699
|
+
async play() {
|
|
700
|
+
const adapter = this.#getActiveAdapter();
|
|
701
|
+
if (!adapter) return;
|
|
702
|
+
|
|
703
|
+
// Ensure the adapter aligns with the global volume before playing
|
|
704
|
+
adapter.setVolume(this.#volume);
|
|
705
|
+
await adapter.play(this.#playlist[this.#currentIndex]);
|
|
706
|
+
this.#isPlaying = true;
|
|
707
|
+
this.emit('play', this.#currentIndex);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Pauses the current track.
|
|
712
|
+
* @returns {Promise<void>}
|
|
713
|
+
*/
|
|
714
|
+
async pause() {
|
|
715
|
+
const adapter = this.#getActiveAdapter();
|
|
716
|
+
if (!adapter) return;
|
|
717
|
+
|
|
718
|
+
await adapter.pause();
|
|
719
|
+
this.#isPlaying = false;
|
|
720
|
+
this.emit('pause', this.#currentIndex);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Stops playback completely.
|
|
725
|
+
* @returns {Promise<void>}
|
|
726
|
+
*/
|
|
727
|
+
async stop() {
|
|
728
|
+
const adapter = this.#getActiveAdapter();
|
|
729
|
+
if (!adapter) return;
|
|
730
|
+
|
|
731
|
+
await adapter.stop();
|
|
732
|
+
this.#isPlaying = false;
|
|
733
|
+
this.emit('stop', this.#currentIndex);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Advances to the next track based on random and loop modes.
|
|
738
|
+
* @returns {Promise<void>}
|
|
739
|
+
*/
|
|
740
|
+
async next() {
|
|
741
|
+
if (this.#playlist.length === 0) return;
|
|
742
|
+
|
|
743
|
+
await this.stop();
|
|
744
|
+
|
|
745
|
+
if (this.#loopMode === 'TRACK') {
|
|
746
|
+
// Index remains the same
|
|
747
|
+
await this.play();
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (this.#isRandom) {
|
|
752
|
+
this.#currentIndex = this._getWeightedRandomIndex();
|
|
753
|
+
} else {
|
|
754
|
+
let nextIndex = this.#currentIndex + 1;
|
|
755
|
+
if (nextIndex >= this.#playlist.length) {
|
|
756
|
+
if (this.#loopMode === 'PLAYLIST') {
|
|
757
|
+
nextIndex = 0;
|
|
758
|
+
} else {
|
|
759
|
+
// End of playlist, stop playing
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
this.#currentIndex = nextIndex;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
this.emit('trackChange', this.#currentIndex);
|
|
767
|
+
await this.play();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Returns to the previous track.
|
|
772
|
+
* @returns {Promise<void>}
|
|
773
|
+
*/
|
|
774
|
+
async prev() {
|
|
775
|
+
if (this.#playlist.length === 0) return;
|
|
776
|
+
|
|
777
|
+
await this.stop();
|
|
778
|
+
|
|
779
|
+
if (this.#isRandom) {
|
|
780
|
+
this.#currentIndex = this._getWeightedRandomIndex();
|
|
781
|
+
} else {
|
|
782
|
+
let prevIndex = this.#currentIndex - 1;
|
|
783
|
+
if (prevIndex < 0) {
|
|
784
|
+
if (this.#loopMode === 'PLAYLIST') {
|
|
785
|
+
prevIndex = this.#playlist.length - 1;
|
|
786
|
+
} else {
|
|
787
|
+
// End of playlist, stop playing
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
this.#currentIndex = prevIndex;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
this.emit('trackChange', this.#currentIndex);
|
|
795
|
+
await this.play();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Jumps to a specific absolute time in the timeline.
|
|
800
|
+
* @param {number} timeMs - The target time in milliseconds.
|
|
801
|
+
* @throws {TypeError} If timeMs is not a number.
|
|
802
|
+
* @throws {RangeError} If timeMs is negative.
|
|
803
|
+
* @returns {Promise<void>}
|
|
804
|
+
*/
|
|
805
|
+
async seek(timeMs) {
|
|
806
|
+
if (typeof timeMs !== 'number') {
|
|
807
|
+
throw new TypeError('Seek time must be a number in milliseconds.');
|
|
808
|
+
}
|
|
809
|
+
if (timeMs < 0) {
|
|
810
|
+
throw new RangeError('Seek time cannot be negative.');
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const adapter = this.#getActiveAdapter();
|
|
814
|
+
if (adapter) {
|
|
815
|
+
await adapter.seek(timeMs);
|
|
816
|
+
this.emit('seek', timeMs);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Moves the timeline forwards or backwards by a specified step amount.
|
|
822
|
+
* @param {number} stepMs - The amount of milliseconds to step (positive for forward, negative for backward).
|
|
823
|
+
* @throws {TypeError} If stepMs is not a number.
|
|
824
|
+
* @returns {Promise<void>}
|
|
825
|
+
*/
|
|
826
|
+
async step(stepMs) {
|
|
827
|
+
if (typeof stepMs !== 'number') {
|
|
828
|
+
throw new TypeError('Step amount must be a number in milliseconds.');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const adapter = this.#getActiveAdapter();
|
|
832
|
+
if (!adapter) return;
|
|
833
|
+
|
|
834
|
+
const currentTime = adapter.getCurrentTime();
|
|
835
|
+
let targetTime = currentTime + stepMs;
|
|
836
|
+
|
|
837
|
+
// Prevent stepping below 0
|
|
838
|
+
if (targetTime < 0) targetTime = 0;
|
|
839
|
+
|
|
840
|
+
await this.seek(targetTime);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
module.exports = TinyMediaPlayer;
|