vidply 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vidply.css +24 -0
- package/dist/vidply.esm.js +217 -34
- package/dist/vidply.esm.js.map +2 -2
- package/dist/vidply.esm.min.js +7 -6
- package/dist/vidply.esm.min.meta.json +7 -7
- package/dist/vidply.js +217 -34
- package/dist/vidply.js.map +2 -2
- package/dist/vidply.min.css +1 -1
- package/dist/vidply.min.js +7 -6
- package/dist/vidply.min.meta.json +7 -7
- package/package.json +57 -54
- package/src/controls/ControlBar.js +2026 -1988
- package/src/core/Player.js +36 -0
- package/src/features/PlaylistManager.js +611 -437
- package/src/styles/vidply.css +24 -0
|
@@ -1,437 +1,611 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VidPly Playlist Manager
|
|
3
|
-
* Manages playlists for audio and video content
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
-
import { createIconElement } from '../icons/Icons.js';
|
|
8
|
-
|
|
9
|
-
export class PlaylistManager {
|
|
10
|
-
constructor(player, options = {}) {
|
|
11
|
-
this.player = player;
|
|
12
|
-
this.tracks = [];
|
|
13
|
-
this.currentIndex = -1;
|
|
14
|
-
|
|
15
|
-
// Options
|
|
16
|
-
this.options = {
|
|
17
|
-
autoAdvance: options.autoAdvance !== false, // Default true
|
|
18
|
-
loop: options.loop || false,
|
|
19
|
-
showPanel: options.showPanel !== false, // Default true
|
|
20
|
-
...options
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
// UI elements
|
|
24
|
-
this.container = null;
|
|
25
|
-
this.playlistPanel = null;
|
|
26
|
-
this.trackInfoElement = null;
|
|
27
|
-
|
|
28
|
-
// Bind methods
|
|
29
|
-
this.handleTrackEnd = this.handleTrackEnd.bind(this);
|
|
30
|
-
this.handleTrackError = this.handleTrackError.bind(this);
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
this.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
this.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
this.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
this.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this.trackInfoElement
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1
|
+
/**
|
|
2
|
+
* VidPly Playlist Manager
|
|
3
|
+
* Manages playlists for audio and video content
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
+
import { createIconElement } from '../icons/Icons.js';
|
|
8
|
+
|
|
9
|
+
export class PlaylistManager {
|
|
10
|
+
constructor(player, options = {}) {
|
|
11
|
+
this.player = player;
|
|
12
|
+
this.tracks = [];
|
|
13
|
+
this.currentIndex = -1;
|
|
14
|
+
|
|
15
|
+
// Options
|
|
16
|
+
this.options = {
|
|
17
|
+
autoAdvance: options.autoAdvance !== false, // Default true
|
|
18
|
+
loop: options.loop || false,
|
|
19
|
+
showPanel: options.showPanel !== false, // Default true
|
|
20
|
+
...options
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// UI elements
|
|
24
|
+
this.container = null;
|
|
25
|
+
this.playlistPanel = null;
|
|
26
|
+
this.trackInfoElement = null;
|
|
27
|
+
|
|
28
|
+
// Bind methods
|
|
29
|
+
this.handleTrackEnd = this.handleTrackEnd.bind(this);
|
|
30
|
+
this.handleTrackError = this.handleTrackError.bind(this);
|
|
31
|
+
|
|
32
|
+
// Register this playlist manager with the player
|
|
33
|
+
this.player.playlistManager = this;
|
|
34
|
+
|
|
35
|
+
// Initialize
|
|
36
|
+
this.init();
|
|
37
|
+
|
|
38
|
+
// Update controls to add playlist buttons
|
|
39
|
+
this.updatePlayerControls();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
init() {
|
|
43
|
+
// Listen for track end
|
|
44
|
+
this.player.on('ended', this.handleTrackEnd);
|
|
45
|
+
this.player.on('error', this.handleTrackError);
|
|
46
|
+
|
|
47
|
+
// Create UI if needed
|
|
48
|
+
if (this.options.showPanel) {
|
|
49
|
+
this.createUI();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Update player controls to add playlist navigation buttons
|
|
55
|
+
*/
|
|
56
|
+
updatePlayerControls() {
|
|
57
|
+
if (!this.player.controlBar) return;
|
|
58
|
+
|
|
59
|
+
const controlBar = this.player.controlBar;
|
|
60
|
+
|
|
61
|
+
// Clear existing controls content (except the element itself)
|
|
62
|
+
controlBar.element.innerHTML = '';
|
|
63
|
+
|
|
64
|
+
// Recreate controls with playlist buttons now available
|
|
65
|
+
controlBar.createControls();
|
|
66
|
+
|
|
67
|
+
// Reattach events for the new controls
|
|
68
|
+
controlBar.attachEvents();
|
|
69
|
+
controlBar.setupAutoHide();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load a playlist
|
|
74
|
+
* @param {Array} tracks - Array of track objects
|
|
75
|
+
*/
|
|
76
|
+
loadPlaylist(tracks) {
|
|
77
|
+
this.tracks = tracks;
|
|
78
|
+
this.currentIndex = -1;
|
|
79
|
+
|
|
80
|
+
// Add playlist class to container
|
|
81
|
+
if (this.container) {
|
|
82
|
+
this.container.classList.add('vidply-has-playlist');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Update UI
|
|
86
|
+
if (this.playlistPanel) {
|
|
87
|
+
this.renderPlaylist();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Auto-play first track
|
|
91
|
+
if (tracks.length > 0) {
|
|
92
|
+
this.play(0);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Play a specific track
|
|
98
|
+
* @param {number} index - Track index
|
|
99
|
+
* @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
|
|
100
|
+
*/
|
|
101
|
+
play(index, userInitiated = false) {
|
|
102
|
+
if (index < 0 || index >= this.tracks.length) {
|
|
103
|
+
console.warn('VidPly Playlist: Invalid track index', index);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const track = this.tracks[index];
|
|
108
|
+
|
|
109
|
+
// Update current index
|
|
110
|
+
this.currentIndex = index;
|
|
111
|
+
|
|
112
|
+
// Load track into player
|
|
113
|
+
this.player.load({
|
|
114
|
+
src: track.src,
|
|
115
|
+
type: track.type,
|
|
116
|
+
poster: track.poster,
|
|
117
|
+
tracks: track.tracks || []
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Update UI
|
|
121
|
+
this.updateTrackInfo(track);
|
|
122
|
+
this.updatePlaylistUI();
|
|
123
|
+
|
|
124
|
+
// Emit event
|
|
125
|
+
this.player.emit('playlisttrackchange', {
|
|
126
|
+
index: index,
|
|
127
|
+
item: track,
|
|
128
|
+
total: this.tracks.length
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Return focus to player for keyboard navigation (only on user action)
|
|
132
|
+
if (userInitiated && this.player.container) {
|
|
133
|
+
this.player.container.focus();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Auto-play
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
this.player.play();
|
|
139
|
+
}, 100);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Play next track
|
|
144
|
+
*/
|
|
145
|
+
next() {
|
|
146
|
+
let nextIndex = this.currentIndex + 1;
|
|
147
|
+
|
|
148
|
+
if (nextIndex >= this.tracks.length) {
|
|
149
|
+
if (this.options.loop) {
|
|
150
|
+
nextIndex = 0;
|
|
151
|
+
} else {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.play(nextIndex);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Play previous track
|
|
161
|
+
*/
|
|
162
|
+
previous() {
|
|
163
|
+
let prevIndex = this.currentIndex - 1;
|
|
164
|
+
|
|
165
|
+
if (prevIndex < 0) {
|
|
166
|
+
if (this.options.loop) {
|
|
167
|
+
prevIndex = this.tracks.length - 1;
|
|
168
|
+
} else {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this.play(prevIndex);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Handle track end
|
|
178
|
+
*/
|
|
179
|
+
handleTrackEnd() {
|
|
180
|
+
if (this.options.autoAdvance) {
|
|
181
|
+
this.next();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Handle track error
|
|
187
|
+
*/
|
|
188
|
+
handleTrackError(e) {
|
|
189
|
+
console.error('VidPly Playlist: Track error', e);
|
|
190
|
+
|
|
191
|
+
// Try next track
|
|
192
|
+
if (this.options.autoAdvance) {
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
this.next();
|
|
195
|
+
}, 1000);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Create playlist UI
|
|
201
|
+
*/
|
|
202
|
+
createUI() {
|
|
203
|
+
// Find player container
|
|
204
|
+
this.container = this.player.container;
|
|
205
|
+
|
|
206
|
+
if (!this.container) {
|
|
207
|
+
console.warn('VidPly Playlist: No container found');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Create track info element (shows current track)
|
|
212
|
+
this.trackInfoElement = DOMUtils.createElement('div', {
|
|
213
|
+
className: 'vidply-track-info',
|
|
214
|
+
role: 'status',
|
|
215
|
+
'aria-live': 'polite',
|
|
216
|
+
'aria-atomic': 'true'
|
|
217
|
+
});
|
|
218
|
+
this.trackInfoElement.style.display = 'none';
|
|
219
|
+
|
|
220
|
+
this.container.appendChild(this.trackInfoElement);
|
|
221
|
+
|
|
222
|
+
// Create playlist panel with proper landmark
|
|
223
|
+
this.playlistPanel = DOMUtils.createElement('div', {
|
|
224
|
+
className: 'vidply-playlist-panel',
|
|
225
|
+
role: 'region',
|
|
226
|
+
'aria-label': 'Media playlist'
|
|
227
|
+
});
|
|
228
|
+
this.playlistPanel.style.display = 'none';
|
|
229
|
+
|
|
230
|
+
this.container.appendChild(this.playlistPanel);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Update track info display
|
|
235
|
+
*/
|
|
236
|
+
updateTrackInfo(track) {
|
|
237
|
+
if (!this.trackInfoElement) return;
|
|
238
|
+
|
|
239
|
+
const trackNumber = this.currentIndex + 1;
|
|
240
|
+
const totalTracks = this.tracks.length;
|
|
241
|
+
const trackTitle = track.title || 'Untitled';
|
|
242
|
+
const trackArtist = track.artist || '';
|
|
243
|
+
|
|
244
|
+
// Screen reader announcement
|
|
245
|
+
const announcement = `Now playing: Track ${trackNumber} of ${totalTracks}. ${trackTitle}${trackArtist ? ' by ' + trackArtist : ''}`;
|
|
246
|
+
|
|
247
|
+
this.trackInfoElement.innerHTML = `
|
|
248
|
+
<span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
|
|
249
|
+
<div class="vidply-track-number" aria-hidden="true">Track ${trackNumber} of ${totalTracks}</div>
|
|
250
|
+
<div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
|
|
251
|
+
${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
|
|
252
|
+
`;
|
|
253
|
+
|
|
254
|
+
this.trackInfoElement.style.display = 'block';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Render playlist
|
|
259
|
+
*/
|
|
260
|
+
renderPlaylist() {
|
|
261
|
+
if (!this.playlistPanel) return;
|
|
262
|
+
|
|
263
|
+
// Clear existing
|
|
264
|
+
this.playlistPanel.innerHTML = '';
|
|
265
|
+
|
|
266
|
+
// Create header
|
|
267
|
+
const header = DOMUtils.createElement('h2', {
|
|
268
|
+
className: 'vidply-playlist-header',
|
|
269
|
+
id: 'vidply-playlist-heading'
|
|
270
|
+
});
|
|
271
|
+
header.textContent = `Playlist (${this.tracks.length})`;
|
|
272
|
+
this.playlistPanel.appendChild(header);
|
|
273
|
+
|
|
274
|
+
// Add keyboard instructions (visually hidden)
|
|
275
|
+
const instructions = DOMUtils.createElement('div', {
|
|
276
|
+
className: 'vidply-sr-only',
|
|
277
|
+
'aria-hidden': 'false'
|
|
278
|
+
});
|
|
279
|
+
instructions.textContent = 'Use arrow keys to navigate between tracks. Press Enter or Space to play a track. Press Home or End to jump to first or last track.';
|
|
280
|
+
this.playlistPanel.appendChild(instructions);
|
|
281
|
+
|
|
282
|
+
// Create list (proper ul element)
|
|
283
|
+
const list = DOMUtils.createElement('ul', {
|
|
284
|
+
className: 'vidply-playlist-list',
|
|
285
|
+
'aria-labelledby': 'vidply-playlist-heading',
|
|
286
|
+
'aria-describedby': 'vidply-playlist-instructions'
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Add list description
|
|
290
|
+
const listDescription = DOMUtils.createElement('div', {
|
|
291
|
+
className: 'vidply-sr-only',
|
|
292
|
+
id: 'vidply-playlist-instructions'
|
|
293
|
+
});
|
|
294
|
+
listDescription.textContent = `Playlist with ${this.tracks.length} ${this.tracks.length === 1 ? 'track' : 'tracks'}`;
|
|
295
|
+
this.playlistPanel.appendChild(listDescription);
|
|
296
|
+
|
|
297
|
+
this.tracks.forEach((track, index) => {
|
|
298
|
+
const item = this.createPlaylistItem(track, index);
|
|
299
|
+
list.appendChild(item);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
this.playlistPanel.appendChild(list);
|
|
303
|
+
this.playlistPanel.style.display = 'block';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Create playlist item element
|
|
308
|
+
*/
|
|
309
|
+
createPlaylistItem(track, index) {
|
|
310
|
+
const trackPosition = `Track ${index + 1} of ${this.tracks.length}`;
|
|
311
|
+
const trackTitle = track.title || `Track ${index + 1}`;
|
|
312
|
+
const trackArtist = track.artist ? ` by ${track.artist}` : '';
|
|
313
|
+
const isActive = index === this.currentIndex;
|
|
314
|
+
const statusText = isActive ? 'Currently playing' : 'Not playing';
|
|
315
|
+
const actionText = isActive ? 'Press Enter to restart' : 'Press Enter to play';
|
|
316
|
+
|
|
317
|
+
const item = DOMUtils.createElement('li', {
|
|
318
|
+
className: 'vidply-playlist-item',
|
|
319
|
+
tabIndex: index === 0 ? 0 : -1, // Only first item is in tab order initially
|
|
320
|
+
'aria-label': `${trackPosition}. ${trackTitle}${trackArtist}. ${statusText}. ${actionText}.`,
|
|
321
|
+
'aria-posinset': index + 1,
|
|
322
|
+
'aria-setsize': this.tracks.length,
|
|
323
|
+
'data-playlist-index': index
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Add active class if current
|
|
327
|
+
if (isActive) {
|
|
328
|
+
item.classList.add('vidply-playlist-item-active');
|
|
329
|
+
item.setAttribute('aria-current', 'true');
|
|
330
|
+
item.setAttribute('tabIndex', '0'); // Active item should always be tabbable
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Add screen reader only position info
|
|
334
|
+
const positionInfo = DOMUtils.createElement('span', {
|
|
335
|
+
className: 'vidply-sr-only'
|
|
336
|
+
});
|
|
337
|
+
positionInfo.textContent = `${trackPosition}: `;
|
|
338
|
+
item.appendChild(positionInfo);
|
|
339
|
+
|
|
340
|
+
// Thumbnail or icon
|
|
341
|
+
const thumbnail = DOMUtils.createElement('div', {
|
|
342
|
+
className: 'vidply-playlist-thumbnail',
|
|
343
|
+
'aria-hidden': 'true'
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (track.poster) {
|
|
347
|
+
thumbnail.style.backgroundImage = `url(${track.poster})`;
|
|
348
|
+
thumbnail.setAttribute('role', 'img');
|
|
349
|
+
thumbnail.setAttribute('aria-label', `${trackTitle} thumbnail`);
|
|
350
|
+
} else {
|
|
351
|
+
// Show music/speaker icon for audio tracks
|
|
352
|
+
const icon = createIconElement('music');
|
|
353
|
+
icon.classList.add('vidply-playlist-thumbnail-icon');
|
|
354
|
+
thumbnail.appendChild(icon);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
item.appendChild(thumbnail);
|
|
358
|
+
|
|
359
|
+
// Info
|
|
360
|
+
const info = DOMUtils.createElement('div', {
|
|
361
|
+
className: 'vidply-playlist-item-info',
|
|
362
|
+
'aria-hidden': 'true'
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const title = DOMUtils.createElement('div', {
|
|
366
|
+
className: 'vidply-playlist-item-title'
|
|
367
|
+
});
|
|
368
|
+
title.textContent = trackTitle;
|
|
369
|
+
info.appendChild(title);
|
|
370
|
+
|
|
371
|
+
if (track.artist) {
|
|
372
|
+
const artist = DOMUtils.createElement('div', {
|
|
373
|
+
className: 'vidply-playlist-item-artist'
|
|
374
|
+
});
|
|
375
|
+
artist.textContent = track.artist;
|
|
376
|
+
info.appendChild(artist);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
item.appendChild(info);
|
|
380
|
+
|
|
381
|
+
// Status indicator for screen readers
|
|
382
|
+
if (isActive) {
|
|
383
|
+
const statusIndicator = DOMUtils.createElement('span', {
|
|
384
|
+
className: 'vidply-sr-only'
|
|
385
|
+
});
|
|
386
|
+
statusIndicator.textContent = ' (Currently playing)';
|
|
387
|
+
item.appendChild(statusIndicator);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Play icon
|
|
391
|
+
const playIcon = createIconElement('play');
|
|
392
|
+
playIcon.classList.add('vidply-playlist-item-icon');
|
|
393
|
+
playIcon.setAttribute('aria-hidden', 'true');
|
|
394
|
+
item.appendChild(playIcon);
|
|
395
|
+
|
|
396
|
+
// Click handler
|
|
397
|
+
item.addEventListener('click', () => {
|
|
398
|
+
this.play(index, true); // User-initiated
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Keyboard handler
|
|
402
|
+
item.addEventListener('keydown', (e) => {
|
|
403
|
+
this.handlePlaylistItemKeydown(e, index);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return item;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Handle keyboard navigation in playlist items
|
|
411
|
+
*/
|
|
412
|
+
handlePlaylistItemKeydown(e, index) {
|
|
413
|
+
const items = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item'));
|
|
414
|
+
let newIndex = -1;
|
|
415
|
+
|
|
416
|
+
switch(e.key) {
|
|
417
|
+
case 'Enter':
|
|
418
|
+
case ' ':
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
this.play(index, true); // User-initiated
|
|
421
|
+
break;
|
|
422
|
+
|
|
423
|
+
case 'ArrowDown':
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
// Move to next item
|
|
426
|
+
if (index < items.length - 1) {
|
|
427
|
+
newIndex = index + 1;
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
|
|
431
|
+
case 'ArrowUp':
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
// Move to previous item
|
|
434
|
+
if (index > 0) {
|
|
435
|
+
newIndex = index - 1;
|
|
436
|
+
}
|
|
437
|
+
break;
|
|
438
|
+
|
|
439
|
+
case 'Home':
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
// Move to first item
|
|
442
|
+
newIndex = 0;
|
|
443
|
+
break;
|
|
444
|
+
|
|
445
|
+
case 'End':
|
|
446
|
+
e.preventDefault();
|
|
447
|
+
// Move to last item
|
|
448
|
+
newIndex = items.length - 1;
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Update tab indices for roving tabindex pattern
|
|
453
|
+
if (newIndex !== -1 && newIndex !== index) {
|
|
454
|
+
items[index].setAttribute('tabIndex', '-1');
|
|
455
|
+
items[newIndex].setAttribute('tabIndex', '0');
|
|
456
|
+
items[newIndex].focus();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Update playlist UI (highlight current track)
|
|
462
|
+
*/
|
|
463
|
+
updatePlaylistUI() {
|
|
464
|
+
if (!this.playlistPanel) return;
|
|
465
|
+
|
|
466
|
+
const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
|
|
467
|
+
|
|
468
|
+
items.forEach((item, index) => {
|
|
469
|
+
const track = this.tracks[index];
|
|
470
|
+
const trackPosition = `Track ${index + 1} of ${this.tracks.length}`;
|
|
471
|
+
const trackTitle = track.title || `Track ${index + 1}`;
|
|
472
|
+
const trackArtist = track.artist ? ` by ${track.artist}` : '';
|
|
473
|
+
|
|
474
|
+
if (index === this.currentIndex) {
|
|
475
|
+
item.classList.add('vidply-playlist-item-active');
|
|
476
|
+
item.setAttribute('aria-current', 'true');
|
|
477
|
+
item.setAttribute('tabIndex', '0'); // Active item should be tabbable
|
|
478
|
+
|
|
479
|
+
const statusText = 'Currently playing';
|
|
480
|
+
const actionText = 'Press Enter to restart';
|
|
481
|
+
item.setAttribute('aria-label', `${trackPosition}. ${trackTitle}${trackArtist}. ${statusText}. ${actionText}.`);
|
|
482
|
+
|
|
483
|
+
// Scroll into view
|
|
484
|
+
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
485
|
+
} else {
|
|
486
|
+
item.classList.remove('vidply-playlist-item-active');
|
|
487
|
+
item.removeAttribute('aria-current');
|
|
488
|
+
item.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
|
|
489
|
+
|
|
490
|
+
const statusText = 'Not playing';
|
|
491
|
+
const actionText = 'Press Enter to play';
|
|
492
|
+
item.setAttribute('aria-label', `${trackPosition}. ${trackTitle}${trackArtist}. ${statusText}. ${actionText}.`);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Get current track
|
|
499
|
+
*/
|
|
500
|
+
getCurrentTrack() {
|
|
501
|
+
return this.tracks[this.currentIndex] || null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Get playlist info
|
|
506
|
+
*/
|
|
507
|
+
getPlaylistInfo() {
|
|
508
|
+
return {
|
|
509
|
+
currentIndex: this.currentIndex,
|
|
510
|
+
totalTracks: this.tracks.length,
|
|
511
|
+
currentTrack: this.getCurrentTrack(),
|
|
512
|
+
hasNext: this.hasNext(),
|
|
513
|
+
hasPrevious: this.hasPrevious()
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Check if there is a next track
|
|
519
|
+
*/
|
|
520
|
+
hasNext() {
|
|
521
|
+
if (this.options.loop) return true;
|
|
522
|
+
return this.currentIndex < this.tracks.length - 1;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Check if there is a previous track
|
|
527
|
+
*/
|
|
528
|
+
hasPrevious() {
|
|
529
|
+
if (this.options.loop) return true;
|
|
530
|
+
return this.currentIndex > 0;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Add track to playlist
|
|
535
|
+
*/
|
|
536
|
+
addTrack(track) {
|
|
537
|
+
this.tracks.push(track);
|
|
538
|
+
|
|
539
|
+
if (this.playlistPanel) {
|
|
540
|
+
this.renderPlaylist();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Remove track from playlist
|
|
546
|
+
*/
|
|
547
|
+
removeTrack(index) {
|
|
548
|
+
if (index < 0 || index >= this.tracks.length) return;
|
|
549
|
+
|
|
550
|
+
this.tracks.splice(index, 1);
|
|
551
|
+
|
|
552
|
+
// Adjust current index if needed
|
|
553
|
+
if (index < this.currentIndex) {
|
|
554
|
+
this.currentIndex--;
|
|
555
|
+
} else if (index === this.currentIndex) {
|
|
556
|
+
// Current track was removed, play next or stop
|
|
557
|
+
if (this.currentIndex >= this.tracks.length) {
|
|
558
|
+
this.currentIndex = this.tracks.length - 1;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (this.currentIndex >= 0) {
|
|
562
|
+
this.play(this.currentIndex);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (this.playlistPanel) {
|
|
567
|
+
this.renderPlaylist();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Clear playlist
|
|
573
|
+
*/
|
|
574
|
+
clear() {
|
|
575
|
+
this.tracks = [];
|
|
576
|
+
this.currentIndex = -1;
|
|
577
|
+
|
|
578
|
+
if (this.playlistPanel) {
|
|
579
|
+
this.playlistPanel.innerHTML = '';
|
|
580
|
+
this.playlistPanel.style.display = 'none';
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (this.trackInfoElement) {
|
|
584
|
+
this.trackInfoElement.innerHTML = '';
|
|
585
|
+
this.trackInfoElement.style.display = 'none';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Destroy playlist manager
|
|
591
|
+
*/
|
|
592
|
+
destroy() {
|
|
593
|
+
// Remove event listeners
|
|
594
|
+
this.player.off('ended', this.handleTrackEnd);
|
|
595
|
+
this.player.off('error', this.handleTrackError);
|
|
596
|
+
|
|
597
|
+
// Remove UI
|
|
598
|
+
if (this.trackInfoElement) {
|
|
599
|
+
this.trackInfoElement.remove();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (this.playlistPanel) {
|
|
603
|
+
this.playlistPanel.remove();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Clear data
|
|
607
|
+
this.clear();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export default PlaylistManager;
|