vidply 1.0.22 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/dev/vidply.HLSRenderer-PNP5OPES.js +255 -0
  2. package/dist/dev/vidply.HLSRenderer-PNP5OPES.js.map +7 -0
  3. package/dist/dev/vidply.HTML5Renderer-LXQ3I45Q.js +12 -0
  4. package/dist/dev/vidply.HTML5Renderer-LXQ3I45Q.js.map +7 -0
  5. package/dist/dev/vidply.TranscriptManager-GZKY44ON.js +1744 -0
  6. package/dist/dev/vidply.TranscriptManager-GZKY44ON.js.map +7 -0
  7. package/dist/dev/vidply.VimeoRenderer-DCETT5IZ.js +213 -0
  8. package/dist/dev/vidply.VimeoRenderer-DCETT5IZ.js.map +7 -0
  9. package/dist/dev/vidply.YouTubeRenderer-QLMMD757.js +227 -0
  10. package/dist/dev/vidply.YouTubeRenderer-QLMMD757.js.map +7 -0
  11. package/dist/dev/vidply.chunk-UEIJOJH6.js +243 -0
  12. package/dist/dev/vidply.chunk-UEIJOJH6.js.map +7 -0
  13. package/dist/dev/vidply.chunk-UH5MTGKF.js +1630 -0
  14. package/dist/dev/vidply.chunk-UH5MTGKF.js.map +7 -0
  15. package/dist/dev/vidply.de-THBIMP4S.js +180 -0
  16. package/dist/dev/vidply.de-THBIMP4S.js.map +7 -0
  17. package/dist/dev/vidply.es-6VWDNNNL.js +180 -0
  18. package/dist/dev/vidply.es-6VWDNNNL.js.map +7 -0
  19. package/dist/{vidply.esm.js → dev/vidply.esm.js} +530 -5082
  20. package/dist/dev/vidply.esm.js.map +7 -0
  21. package/dist/dev/vidply.fr-WHTWCHWT.js +180 -0
  22. package/dist/dev/vidply.fr-WHTWCHWT.js.map +7 -0
  23. package/dist/dev/vidply.ja-BFQNPOFI.js +180 -0
  24. package/dist/dev/vidply.ja-BFQNPOFI.js.map +7 -0
  25. package/dist/{vidply.js → legacy/vidply.js} +7833 -7317
  26. package/dist/legacy/vidply.js.map +7 -0
  27. package/dist/legacy/vidply.min.js +6 -0
  28. package/dist/{vidply.min.meta.json → legacy/vidply.min.meta.json} +120 -94
  29. package/dist/prod/vidply.HLSRenderer-4PW35TCX.min.js +6 -0
  30. package/dist/prod/vidply.HTML5Renderer-XJCSUETP.min.js +6 -0
  31. package/dist/prod/vidply.TranscriptManager-UZ6DUFB6.min.js +6 -0
  32. package/dist/prod/vidply.VimeoRenderer-P3PU27S7.min.js +6 -0
  33. package/dist/prod/vidply.YouTubeRenderer-DGKKWB5M.min.js +6 -0
  34. package/dist/prod/vidply.chunk-BQBGEJF7.min.js +6 -0
  35. package/dist/prod/vidply.chunk-MBUR3U5L.min.js +6 -0
  36. package/dist/prod/vidply.de-SWFW4HYT.min.js +6 -0
  37. package/dist/prod/vidply.es-7BJ2DJAY.min.js +6 -0
  38. package/dist/prod/vidply.esm.min.js +21 -0
  39. package/dist/prod/vidply.fr-DPVR5DFY.min.js +6 -0
  40. package/dist/prod/vidply.ja-PEBVWKVH.min.js +6 -0
  41. package/dist/vidply.css +184 -4
  42. package/dist/vidply.esm.min.meta.json +284 -102
  43. package/dist/vidply.min.css +1 -1
  44. package/package.json +4 -4
  45. package/src/controls/ControlBar.js +3341 -3246
  46. package/src/controls/TranscriptManager.js +2296 -2271
  47. package/src/core/Player.js +4807 -4730
  48. package/src/features/PlaylistManager.js +1203 -1039
  49. package/src/i18n/i18n.js +51 -7
  50. package/src/i18n/languages/de.js +5 -1
  51. package/src/i18n/languages/en.js +5 -1
  52. package/src/i18n/languages/es.js +5 -1
  53. package/src/i18n/languages/fr.js +5 -1
  54. package/src/i18n/languages/ja.js +5 -1
  55. package/src/i18n/translations.js +35 -18
  56. package/src/icons/Icons.js +2 -20
  57. package/src/renderers/HLSRenderer.js +7 -0
  58. package/src/styles/vidply.css +184 -4
  59. package/src/utils/DOMUtils.js +67 -0
  60. package/src/utils/MenuUtils.js +10 -4
  61. package/src/utils/SettingsMenuFactory.js +8 -4
  62. package/src/utils/WindowComponents.js +6 -4
  63. package/dist/vidply.esm.js.map +0 -7
  64. package/dist/vidply.esm.min.js +0 -18
  65. package/dist/vidply.js.map +0 -7
  66. package/dist/vidply.min.js +0 -18
@@ -1,1039 +1,1203 @@
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
- import { i18n } from '../i18n/i18n.js';
9
-
10
- // Static counter for unique IDs
11
- let playlistInstanceCounter = 0;
12
-
13
- export class PlaylistManager {
14
- constructor(player, options = {}) {
15
- this.player = player;
16
- this.tracks = [];
17
- this.currentIndex = -1;
18
-
19
- // Generate unique instance ID for this playlist
20
- this.instanceId = ++playlistInstanceCounter;
21
- this.uniqueId = `vidply-playlist-${this.instanceId}`;
22
-
23
- // Options
24
- this.options = {
25
- autoAdvance: options.autoAdvance !== false, // Default true
26
- autoPlayFirst: options.autoPlayFirst !== false, // Default true - auto-play first track on load
27
- loop: options.loop || false,
28
- showPanel: options.showPanel !== false, // Default true
29
- ...options
30
- };
31
-
32
- // UI elements
33
- this.container = null;
34
- this.playlistPanel = null;
35
- this.trackInfoElement = null;
36
- this.navigationFeedback = null; // Live region for keyboard navigation feedback
37
- this.isPanelVisible = this.options.showPanel !== false;
38
-
39
- // Track change guard to prevent cascade of next() calls
40
- this.isChangingTrack = false;
41
-
42
- // Bind methods
43
- this.handleTrackEnd = this.handleTrackEnd.bind(this);
44
- this.handleTrackError = this.handleTrackError.bind(this);
45
-
46
- // Register this playlist manager with the player
47
- this.player.playlistManager = this;
48
-
49
- // Initialize
50
- this.init();
51
-
52
- // Update controls to add playlist buttons
53
- this.updatePlayerControls();
54
-
55
- // Load tracks if provided in options
56
- if (options.tracks && Array.isArray(options.tracks)) {
57
- this.loadPlaylist(options.tracks);
58
- }
59
- }
60
-
61
- init() {
62
- // Listen for track end
63
- this.player.on('ended', this.handleTrackEnd);
64
- this.player.on('error', this.handleTrackError);
65
-
66
- // Listen for playback state changes to show/hide playlist in fullscreen
67
- this.player.on('play', this.handlePlaybackStateChange.bind(this));
68
- this.player.on('pause', this.handlePlaybackStateChange.bind(this));
69
- this.player.on('ended', this.handlePlaybackStateChange.bind(this));
70
- // Use fullscreenchange event which is what the player actually emits
71
- this.player.on('fullscreenchange', this.handleFullscreenChange.bind(this));
72
-
73
- // Create UI if needed
74
- if (this.options.showPanel) {
75
- this.createUI();
76
- }
77
-
78
- // Check for data-playlist attribute on player container (only if tracks weren't provided in options)
79
- if (this.tracks.length === 0) {
80
- this.loadPlaylistFromAttribute();
81
- }
82
- }
83
-
84
- /**
85
- * Load playlist from data-playlist attribute if present
86
- */
87
- loadPlaylistFromAttribute() {
88
- // Check the original wrapper element for data-playlist
89
- // Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
90
- // So we need to go up 3 levels
91
- if (!this.player.element || !this.player.element.parentElement) {
92
- console.log('VidPly Playlist: No player element found');
93
- return;
94
- }
95
-
96
- const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
97
- const playerContainer = videoWrapper.parentElement; // .vidply-player
98
- const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
99
-
100
- if (!originalElement) {
101
- console.log('VidPly Playlist: No original element found');
102
- return;
103
- }
104
-
105
- // Load playlist options from data attributes
106
- this.loadOptionsFromAttributes(originalElement);
107
-
108
- const playlistData = originalElement.getAttribute('data-playlist');
109
- if (!playlistData) {
110
- console.log('VidPly Playlist: No data-playlist attribute found');
111
- return;
112
- }
113
-
114
- console.log('VidPly Playlist: Found data-playlist attribute, parsing...');
115
- try {
116
- const tracks = JSON.parse(playlistData);
117
- if (Array.isArray(tracks) && tracks.length > 0) {
118
- console.log(`VidPly Playlist: Loaded ${tracks.length} tracks from data-playlist`);
119
- this.loadPlaylist(tracks);
120
- } else {
121
- console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
122
- }
123
- } catch (error) {
124
- console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
125
- }
126
- }
127
-
128
- /**
129
- * Load playlist options from data attributes
130
- * @param {HTMLElement} element - Element to read attributes from
131
- */
132
- loadOptionsFromAttributes(element) {
133
- // data-playlist-auto-advance
134
- const autoAdvance = element.getAttribute('data-playlist-auto-advance');
135
- if (autoAdvance !== null) {
136
- this.options.autoAdvance = autoAdvance === 'true';
137
- }
138
-
139
- // data-playlist-auto-play-first
140
- const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
141
- if (autoPlayFirst !== null) {
142
- this.options.autoPlayFirst = autoPlayFirst === 'true';
143
- }
144
-
145
- // data-playlist-loop
146
- const loop = element.getAttribute('data-playlist-loop');
147
- if (loop !== null) {
148
- this.options.loop = loop === 'true';
149
- }
150
-
151
- // data-playlist-show-panel
152
- const showPanel = element.getAttribute('data-playlist-show-panel');
153
- if (showPanel !== null) {
154
- this.options.showPanel = showPanel === 'true';
155
- }
156
-
157
- console.log('VidPly Playlist: Options from attributes:', this.options);
158
- }
159
-
160
- /**
161
- * Update player controls to add playlist navigation buttons
162
- */
163
- updatePlayerControls() {
164
- if (!this.player.controlBar) return;
165
-
166
- const controlBar = this.player.controlBar;
167
-
168
- // Clear existing controls content (except the element itself)
169
- controlBar.element.innerHTML = '';
170
-
171
- // Recreate controls with playlist buttons now available
172
- controlBar.createControls();
173
-
174
- // Reattach events for the new controls
175
- controlBar.attachEvents();
176
- controlBar.setupAutoHide();
177
- }
178
-
179
- /**
180
- * Load a playlist
181
- * @param {Array} tracks - Array of track objects
182
- */
183
- loadPlaylist(tracks) {
184
- this.tracks = tracks;
185
- this.currentIndex = -1;
186
-
187
- // Add playlist class to container
188
- if (this.container) {
189
- this.container.classList.add('vidply-has-playlist');
190
- }
191
-
192
- // Update UI
193
- if (this.playlistPanel) {
194
- this.renderPlaylist();
195
- }
196
-
197
- // Auto-play first track (if enabled)
198
- if (tracks.length > 0) {
199
- if (this.options.autoPlayFirst) {
200
- this.play(0);
201
- } else {
202
- // Load first track without playing
203
- this.loadTrack(0);
204
- }
205
- }
206
-
207
- // Update visibility based on current state
208
- this.updatePlaylistVisibilityInFullscreen();
209
- }
210
-
211
- /**
212
- * Load a track without playing
213
- * @param {number} index - Track index
214
- */
215
- loadTrack(index) {
216
- if (index < 0 || index >= this.tracks.length) {
217
- console.warn('VidPly Playlist: Invalid track index', index);
218
- return;
219
- }
220
-
221
- const track = this.tracks[index];
222
-
223
- // Set guard flag to prevent cascade of next() calls during track change
224
- this.isChangingTrack = true;
225
-
226
- // Update current index
227
- this.currentIndex = index;
228
-
229
- // Load track into player
230
- this.player.load({
231
- src: track.src,
232
- type: track.type,
233
- poster: track.poster,
234
- tracks: track.tracks || [],
235
- audioDescriptionSrc: track.audioDescriptionSrc || null,
236
- signLanguageSrc: track.signLanguageSrc || null
237
- });
238
-
239
- // Update UI
240
- this.updateTrackInfo(track);
241
- this.updatePlaylistUI();
242
-
243
- // Emit event
244
- this.player.emit('playlisttrackchange', {
245
- index: index,
246
- item: track,
247
- total: this.tracks.length
248
- });
249
-
250
- // Clear guard flag after a short delay to ensure track is loaded
251
- setTimeout(() => {
252
- this.isChangingTrack = false;
253
- }, 150);
254
- }
255
-
256
- /**
257
- * Play a specific track
258
- * @param {number} index - Track index
259
- * @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
260
- */
261
- play(index, userInitiated = false) {
262
- if (index < 0 || index >= this.tracks.length) {
263
- console.warn('VidPly Playlist: Invalid track index', index);
264
- return;
265
- }
266
-
267
- const track = this.tracks[index];
268
-
269
- // Set guard flag to prevent cascade of next() calls during track change
270
- this.isChangingTrack = true;
271
-
272
- // Update current index
273
- this.currentIndex = index;
274
-
275
- // Load track into player
276
- this.player.load({
277
- src: track.src,
278
- type: track.type,
279
- poster: track.poster,
280
- tracks: track.tracks || [],
281
- audioDescriptionSrc: track.audioDescriptionSrc || null,
282
- signLanguageSrc: track.signLanguageSrc || null
283
- });
284
-
285
- // Update UI
286
- this.updateTrackInfo(track);
287
- this.updatePlaylistUI();
288
-
289
- // Emit event
290
- this.player.emit('playlisttrackchange', {
291
- index: index,
292
- item: track,
293
- total: this.tracks.length
294
- });
295
-
296
- // Auto-play and clear guard flag after playback starts
297
- setTimeout(() => {
298
- this.player.play();
299
- // Clear guard flag after a short delay to ensure track has started
300
- setTimeout(() => {
301
- this.isChangingTrack = false;
302
- }, 50);
303
- }, 100);
304
- }
305
-
306
- /**
307
- * Play next track
308
- */
309
- next() {
310
- let nextIndex = this.currentIndex + 1;
311
-
312
- if (nextIndex >= this.tracks.length) {
313
- if (this.options.loop) {
314
- nextIndex = 0;
315
- } else {
316
- return;
317
- }
318
- }
319
-
320
- this.play(nextIndex);
321
- }
322
-
323
- /**
324
- * Play previous track
325
- */
326
- previous() {
327
- let prevIndex = this.currentIndex - 1;
328
-
329
- if (prevIndex < 0) {
330
- if (this.options.loop) {
331
- prevIndex = this.tracks.length - 1;
332
- } else {
333
- return;
334
- }
335
- }
336
-
337
- this.play(prevIndex);
338
- }
339
-
340
- /**
341
- * Handle track end
342
- */
343
- handleTrackEnd() {
344
- // Don't auto-advance if we're already in the process of changing tracks
345
- // This prevents a cascade of next() calls when loading a new track triggers an 'ended' event
346
- if (this.isChangingTrack) {
347
- return;
348
- }
349
-
350
- if (this.options.autoAdvance) {
351
- this.next();
352
- }
353
- }
354
-
355
- /**
356
- * Handle track error
357
- */
358
- handleTrackError(e) {
359
- console.error('VidPly Playlist: Track error', e);
360
-
361
- // Try next track
362
- if (this.options.autoAdvance) {
363
- setTimeout(() => {
364
- this.next();
365
- }, 1000);
366
- }
367
- }
368
-
369
- /**
370
- * Handle playback state changes (for fullscreen playlist visibility)
371
- */
372
- handlePlaybackStateChange() {
373
- this.updatePlaylistVisibilityInFullscreen();
374
- }
375
-
376
- /**
377
- * Handle fullscreen state changes
378
- */
379
- handleFullscreenChange() {
380
- // Use a small delay to ensure fullscreen state is fully applied
381
- setTimeout(() => {
382
- this.updatePlaylistVisibilityInFullscreen();
383
- }, 50);
384
- }
385
-
386
- /**
387
- * Update playlist visibility based on fullscreen and playback state
388
- * In fullscreen: show when paused/not started, hide when playing
389
- * Outside fullscreen: respect original panel visibility setting
390
- */
391
- updatePlaylistVisibilityInFullscreen() {
392
- if (!this.playlistPanel || !this.tracks.length) return;
393
-
394
- const isFullscreen = this.player.state.fullscreen;
395
- const isPlaying = this.player.state.playing;
396
-
397
- if (isFullscreen) {
398
- // In fullscreen: show only when not playing (paused or not started)
399
- // Check playing state explicitly since paused might not be set initially
400
- if (!isPlaying) {
401
- this.playlistPanel.classList.add('vidply-playlist-fullscreen-visible');
402
- this.playlistPanel.style.display = 'block';
403
- } else {
404
- this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
405
- // Add a smooth fade out with delay to match CSS transition
406
- setTimeout(() => {
407
- // Double-check state hasn't changed before hiding
408
- if (this.player.state.playing && this.player.state.fullscreen) {
409
- this.playlistPanel.style.display = 'none';
410
- }
411
- }, 300); // Match CSS transition duration
412
- }
413
- } else {
414
- // Outside fullscreen: restore original behavior
415
- this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
416
- if (this.isPanelVisible && this.tracks.length > 0) {
417
- this.playlistPanel.style.display = 'block';
418
- } else {
419
- this.playlistPanel.style.display = 'none';
420
- }
421
- }
422
- }
423
-
424
- /**
425
- * Create playlist UI
426
- */
427
- createUI() {
428
- // Find player container
429
- this.container = this.player.container;
430
-
431
- if (!this.container) {
432
- console.warn('VidPly Playlist: No container found');
433
- return;
434
- }
435
-
436
- // Create track artwork element (shows album art/poster for audio playlists)
437
- // Only create for audio players
438
- if (this.player.element.tagName === 'AUDIO') {
439
- this.trackArtworkElement = DOMUtils.createElement('div', {
440
- className: 'vidply-track-artwork',
441
- attributes: {
442
- 'aria-hidden': 'true'
443
- }
444
- });
445
- this.trackArtworkElement.style.display = 'none';
446
-
447
- // Insert before video wrapper
448
- const videoWrapper = this.container.querySelector('.vidply-video-wrapper');
449
- if (videoWrapper) {
450
- this.container.insertBefore(this.trackArtworkElement, videoWrapper);
451
- } else {
452
- this.container.appendChild(this.trackArtworkElement);
453
- }
454
- }
455
-
456
- // Create track info element (shows current track)
457
- this.trackInfoElement = DOMUtils.createElement('div', {
458
- className: 'vidply-track-info',
459
- attributes: {
460
- role: 'status',
461
- 'aria-live': 'polite',
462
- 'aria-atomic': 'true'
463
- }
464
- });
465
- this.trackInfoElement.style.display = 'none';
466
-
467
- this.container.appendChild(this.trackInfoElement);
468
-
469
- // Create navigation feedback live region
470
- this.navigationFeedback = DOMUtils.createElement('div', {
471
- className: 'vidply-sr-only',
472
- attributes: {
473
- role: 'status',
474
- 'aria-live': 'polite',
475
- 'aria-atomic': 'true'
476
- }
477
- });
478
- this.container.appendChild(this.navigationFeedback);
479
-
480
- // Create playlist panel with proper landmark
481
- this.playlistPanel = DOMUtils.createElement('div', {
482
- className: 'vidply-playlist-panel',
483
- attributes: {
484
- id: `${this.uniqueId}-panel`,
485
- role: 'region',
486
- 'aria-label': i18n.t('playlist.title'),
487
- 'aria-labelledby': `${this.uniqueId}-heading`
488
- }
489
- });
490
- this.playlistPanel.style.display = this.isPanelVisible ? 'none' : 'none'; // Will be shown when playlist is loaded
491
-
492
- this.container.appendChild(this.playlistPanel);
493
- }
494
-
495
- /**
496
- * Update track info display
497
- */
498
- updateTrackInfo(track) {
499
- if (!this.trackInfoElement) return;
500
-
501
- const trackNumber = this.currentIndex + 1;
502
- const totalTracks = this.tracks.length;
503
- const trackTitle = track.title || i18n.t('playlist.untitled');
504
- const trackArtist = track.artist || '';
505
-
506
- // Screen reader announcement
507
- const artistPart = trackArtist ? i18n.t('playlist.by') + trackArtist : '';
508
- const announcement = i18n.t('playlist.nowPlaying', {
509
- current: trackNumber,
510
- total: totalTracks,
511
- title: trackTitle,
512
- artist: artistPart
513
- });
514
-
515
- const trackOfText = i18n.t('playlist.trackOf', {
516
- current: trackNumber,
517
- total: totalTracks
518
- });
519
-
520
- this.trackInfoElement.innerHTML = `
521
- <span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
522
- <div class="vidply-track-number" aria-hidden="true">${DOMUtils.escapeHTML(trackOfText)}</div>
523
- <div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
524
- ${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
525
- `;
526
-
527
- this.trackInfoElement.style.display = 'block';
528
-
529
- // Update track artwork if available (for audio playlists)
530
- this.updateTrackArtwork(track);
531
- }
532
-
533
- /**
534
- * Update track artwork display (for audio playlists)
535
- */
536
- updateTrackArtwork(track) {
537
- if (!this.trackArtworkElement) return;
538
-
539
- // If track has a poster/artwork, show it
540
- if (track.poster) {
541
- this.trackArtworkElement.style.backgroundImage = `url(${track.poster})`;
542
- this.trackArtworkElement.style.display = 'block';
543
- } else {
544
- // No artwork available, hide the element
545
- this.trackArtworkElement.style.display = 'none';
546
- }
547
- }
548
-
549
- /**
550
- * Render playlist
551
- */
552
- renderPlaylist() {
553
- if (!this.playlistPanel) return;
554
-
555
- // Clear existing
556
- this.playlistPanel.innerHTML = '';
557
-
558
- // Create header
559
- const header = DOMUtils.createElement('h2', {
560
- className: 'vidply-playlist-header',
561
- attributes: {
562
- id: `${this.uniqueId}-heading`
563
- }
564
- });
565
- header.textContent = `${i18n.t('playlist.title')} (${this.tracks.length})`;
566
- this.playlistPanel.appendChild(header);
567
-
568
- // Add keyboard instructions (visually hidden)
569
- const instructions = DOMUtils.createElement('div', {
570
- className: 'vidply-sr-only',
571
- attributes: {
572
- id: `${this.uniqueId}-keyboard-instructions`
573
- }
574
- });
575
- instructions.textContent = 'Playlist navigation: Use Up and Down arrow keys to move between tracks. Press Page Up or Page Down to skip 5 tracks. Press Home to go to first track, End to go to last track. Press Enter or Space to play the selected track.';
576
- this.playlistPanel.appendChild(instructions);
577
-
578
- // Create list (proper ul element)
579
- const list = DOMUtils.createElement('ul', {
580
- className: 'vidply-playlist-list',
581
- attributes: {
582
- 'aria-labelledby': `${this.uniqueId}-heading`,
583
- 'aria-describedby': `${this.uniqueId}-keyboard-instructions`
584
- }
585
- });
586
-
587
- this.tracks.forEach((track, index) => {
588
- const item = this.createPlaylistItem(track, index);
589
- list.appendChild(item);
590
- });
591
-
592
- this.playlistPanel.appendChild(list);
593
-
594
- // Show panel if it should be visible
595
- if (this.isPanelVisible) {
596
- this.playlistPanel.style.display = 'block';
597
- }
598
- }
599
-
600
- /**
601
- * Create playlist item element
602
- */
603
- createPlaylistItem(track, index) {
604
- const trackPosition = i18n.t('playlist.trackOf', {
605
- current: index + 1,
606
- total: this.tracks.length
607
- });
608
- const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
609
- const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
610
- const isActive = index === this.currentIndex;
611
- const statusText = isActive ? 'Currently playing' : 'Not playing';
612
- const actionText = isActive ? 'Press Enter to restart' : 'Press Enter to play';
613
-
614
- // Create list item container (semantic HTML)
615
- const item = DOMUtils.createElement('li', {
616
- className: isActive ? 'vidply-playlist-item vidply-playlist-item-active' : 'vidply-playlist-item',
617
- attributes: {
618
- 'data-playlist-index': index
619
- }
620
- });
621
-
622
- // Create button wrapper for interactive content
623
- const button = DOMUtils.createElement('button', {
624
- className: 'vidply-playlist-item-button',
625
- attributes: {
626
- type: 'button',
627
- tabIndex: index === 0 ? 0 : -1, // Only first item is in tab order initially
628
- 'aria-label': `${trackPosition}. ${trackTitle}${trackArtist}. ${statusText}. ${actionText}.`,
629
- 'aria-posinset': index + 1,
630
- 'aria-setsize': this.tracks.length
631
- }
632
- });
633
-
634
- // Add aria-current if active
635
- if (isActive) {
636
- button.setAttribute('aria-current', 'true');
637
- button.setAttribute('tabIndex', '0'); // Active item should always be tabbable
638
- }
639
-
640
- // Thumbnail or icon (using span for valid button content)
641
- const thumbnail = DOMUtils.createElement('span', {
642
- className: 'vidply-playlist-thumbnail',
643
- attributes: {
644
- 'aria-hidden': 'true'
645
- }
646
- });
647
-
648
- if (track.poster) {
649
- thumbnail.style.backgroundImage = `url(${track.poster})`;
650
- } else {
651
- // Show music/speaker icon for audio tracks
652
- const icon = createIconElement('music');
653
- icon.classList.add('vidply-playlist-thumbnail-icon');
654
- thumbnail.appendChild(icon);
655
- }
656
-
657
- button.appendChild(thumbnail);
658
-
659
- // Info (using span for valid button content)
660
- const info = DOMUtils.createElement('span', {
661
- className: 'vidply-playlist-item-info',
662
- attributes: {
663
- 'aria-hidden': 'true'
664
- }
665
- });
666
-
667
- const title = DOMUtils.createElement('span', {
668
- className: 'vidply-playlist-item-title'
669
- });
670
- title.textContent = trackTitle;
671
- info.appendChild(title);
672
-
673
- if (track.artist) {
674
- const artist = DOMUtils.createElement('span', {
675
- className: 'vidply-playlist-item-artist'
676
- });
677
- artist.textContent = track.artist;
678
- info.appendChild(artist);
679
- }
680
-
681
- button.appendChild(info);
682
-
683
- // Play icon
684
- const playIcon = createIconElement('play');
685
- playIcon.classList.add('vidply-playlist-item-icon');
686
- playIcon.setAttribute('aria-hidden', 'true');
687
- button.appendChild(playIcon);
688
-
689
- // Click handler
690
- button.addEventListener('click', () => {
691
- this.play(index, true); // User-initiated
692
- });
693
-
694
- // Keyboard handler
695
- button.addEventListener('keydown', (e) => {
696
- this.handlePlaylistItemKeydown(e, index);
697
- });
698
-
699
- // Append button to list item
700
- item.appendChild(button);
701
-
702
- return item;
703
- }
704
-
705
- /**
706
- * Handle keyboard navigation in playlist items
707
- */
708
- handlePlaylistItemKeydown(e, index) {
709
- const buttons = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item-button'));
710
- let newIndex = -1;
711
- let announcement = '';
712
-
713
- switch(e.key) {
714
- case 'Enter':
715
- case ' ':
716
- e.preventDefault();
717
- e.stopPropagation();
718
- this.play(index, true); // User-initiated
719
- return; // No need to move focus
720
-
721
- case 'ArrowDown':
722
- e.preventDefault();
723
- e.stopPropagation();
724
- // Move to next item
725
- if (index < buttons.length - 1) {
726
- newIndex = index + 1;
727
- } else {
728
- // At the end, announce boundary
729
- announcement = `End of playlist. ${buttons.length} of ${buttons.length}.`;
730
- }
731
- break;
732
-
733
- case 'ArrowUp':
734
- e.preventDefault();
735
- e.stopPropagation();
736
- // Move to previous item
737
- if (index > 0) {
738
- newIndex = index - 1;
739
- } else {
740
- // At the beginning, announce boundary
741
- announcement = 'Beginning of playlist. 1 of ' + buttons.length + '.';
742
- }
743
- break;
744
-
745
- case 'PageDown':
746
- e.preventDefault();
747
- e.stopPropagation();
748
- // Move 5 items down (or to end)
749
- newIndex = Math.min(index + 5, buttons.length - 1);
750
- if (newIndex === buttons.length - 1 && index !== newIndex) {
751
- announcement = `Jumped to last track. ${newIndex + 1} of ${buttons.length}.`;
752
- }
753
- break;
754
-
755
- case 'PageUp':
756
- e.preventDefault();
757
- e.stopPropagation();
758
- // Move 5 items up (or to beginning)
759
- newIndex = Math.max(index - 5, 0);
760
- if (newIndex === 0 && index !== newIndex) {
761
- announcement = `Jumped to first track. 1 of ${buttons.length}.`;
762
- }
763
- break;
764
-
765
- case 'Home':
766
- e.preventDefault();
767
- e.stopPropagation();
768
- // Move to first item
769
- newIndex = 0;
770
- if (index !== 0) {
771
- announcement = `First track. 1 of ${buttons.length}.`;
772
- }
773
- break;
774
-
775
- case 'End':
776
- e.preventDefault();
777
- e.stopPropagation();
778
- // Move to last item
779
- newIndex = buttons.length - 1;
780
- if (index !== buttons.length - 1) {
781
- announcement = `Last track. ${buttons.length} of ${buttons.length}.`;
782
- }
783
- break;
784
- }
785
-
786
- // Update tab indices for roving tabindex pattern
787
- if (newIndex !== -1 && newIndex !== index) {
788
- buttons[index].setAttribute('tabIndex', '-1');
789
- buttons[newIndex].setAttribute('tabIndex', '0');
790
- buttons[newIndex].focus();
791
- }
792
-
793
- // Announce navigation feedback
794
- if (announcement && this.navigationFeedback) {
795
- this.navigationFeedback.textContent = announcement;
796
- // Clear after a short delay to allow for repeated announcements
797
- setTimeout(() => {
798
- if (this.navigationFeedback) {
799
- this.navigationFeedback.textContent = '';
800
- }
801
- }, 1000);
802
- }
803
- }
804
-
805
- /**
806
- * Update playlist UI (highlight current track)
807
- */
808
- updatePlaylistUI() {
809
- if (!this.playlistPanel) return;
810
-
811
- const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
812
- const buttons = this.playlistPanel.querySelectorAll('.vidply-playlist-item-button');
813
-
814
- items.forEach((item, index) => {
815
- const button = buttons[index];
816
- if (!button) return;
817
-
818
- const track = this.tracks[index];
819
- const trackPosition = i18n.t('playlist.trackOf', {
820
- current: index + 1,
821
- total: this.tracks.length
822
- });
823
- const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
824
- const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
825
-
826
- if (index === this.currentIndex) {
827
- // Update list item styling
828
- item.classList.add('vidply-playlist-item-active');
829
-
830
- // Update button ARIA attributes
831
- button.setAttribute('aria-current', 'true');
832
- button.setAttribute('tabIndex', '0'); // Active item should be tabbable
833
-
834
- const statusText = 'Currently playing';
835
- const actionText = 'Press Enter to restart';
836
- button.setAttribute('aria-label', `${trackPosition}. ${trackTitle}${trackArtist}. ${statusText}. ${actionText}.`);
837
-
838
- // Scroll into view within playlist panel (uses 'nearest' to minimize page scroll)
839
- item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
840
- } else {
841
- // Update list item styling
842
- item.classList.remove('vidply-playlist-item-active');
843
-
844
- // Update button ARIA attributes
845
- button.removeAttribute('aria-current');
846
- button.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
847
-
848
- const statusText = 'Not playing';
849
- const actionText = 'Press Enter to play';
850
- button.setAttribute('aria-label', `${trackPosition}. ${trackTitle}${trackArtist}. ${statusText}. ${actionText}.`);
851
- }
852
- });
853
- }
854
-
855
- /**
856
- * Get current track
857
- */
858
- getCurrentTrack() {
859
- return this.tracks[this.currentIndex] || null;
860
- }
861
-
862
- /**
863
- * Get playlist info
864
- */
865
- getPlaylistInfo() {
866
- return {
867
- currentIndex: this.currentIndex,
868
- totalTracks: this.tracks.length,
869
- currentTrack: this.getCurrentTrack(),
870
- hasNext: this.hasNext(),
871
- hasPrevious: this.hasPrevious()
872
- };
873
- }
874
-
875
- /**
876
- * Check if there is a next track
877
- */
878
- hasNext() {
879
- if (this.options.loop) return true;
880
- return this.currentIndex < this.tracks.length - 1;
881
- }
882
-
883
- /**
884
- * Check if there is a previous track
885
- */
886
- hasPrevious() {
887
- if (this.options.loop) return true;
888
- return this.currentIndex > 0;
889
- }
890
-
891
- /**
892
- * Add track to playlist
893
- */
894
- addTrack(track) {
895
- this.tracks.push(track);
896
-
897
- if (this.playlistPanel) {
898
- this.renderPlaylist();
899
- }
900
- }
901
-
902
- /**
903
- * Remove track from playlist
904
- */
905
- removeTrack(index) {
906
- if (index < 0 || index >= this.tracks.length) return;
907
-
908
- this.tracks.splice(index, 1);
909
-
910
- // Adjust current index if needed
911
- if (index < this.currentIndex) {
912
- this.currentIndex--;
913
- } else if (index === this.currentIndex) {
914
- // Current track was removed, play next or stop
915
- if (this.currentIndex >= this.tracks.length) {
916
- this.currentIndex = this.tracks.length - 1;
917
- }
918
-
919
- if (this.currentIndex >= 0) {
920
- this.play(this.currentIndex);
921
- }
922
- }
923
-
924
- if (this.playlistPanel) {
925
- this.renderPlaylist();
926
- }
927
- }
928
-
929
- /**
930
- * Clear playlist
931
- */
932
- clear() {
933
- this.tracks = [];
934
- this.currentIndex = -1;
935
-
936
- if (this.playlistPanel) {
937
- this.playlistPanel.innerHTML = '';
938
- this.playlistPanel.style.display = 'none';
939
- }
940
-
941
- if (this.trackInfoElement) {
942
- this.trackInfoElement.innerHTML = '';
943
- this.trackInfoElement.style.display = 'none';
944
- }
945
-
946
- if (this.trackArtworkElement) {
947
- this.trackArtworkElement.style.backgroundImage = '';
948
- this.trackArtworkElement.style.display = 'none';
949
- }
950
- }
951
-
952
- /**
953
- * Toggle playlist panel visibility
954
- * @param {boolean} show - Optional: force show (true) or hide (false)
955
- * @returns {boolean} - New visibility state
956
- */
957
- togglePanel(show) {
958
- if (!this.playlistPanel) return false;
959
-
960
- // Determine new state
961
- const shouldShow = show !== undefined ? show : this.playlistPanel.style.display === 'none';
962
-
963
- if (shouldShow) {
964
- this.playlistPanel.style.display = 'block';
965
- this.isPanelVisible = true;
966
-
967
- // Focus first item if playlist has tracks
968
- if (this.tracks.length > 0) {
969
- setTimeout(() => {
970
- const firstItem = this.playlistPanel.querySelector('.vidply-playlist-item[tabindex="0"]');
971
- if (firstItem) {
972
- firstItem.focus();
973
- }
974
- }, 100);
975
- }
976
-
977
- // Update toggle button state if it exists
978
- if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
979
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'true');
980
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'true');
981
- }
982
- } else {
983
- this.playlistPanel.style.display = 'none';
984
- this.isPanelVisible = false;
985
-
986
- // Update toggle button state if it exists
987
- if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
988
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'false');
989
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'false');
990
-
991
- // Return focus to toggle button
992
- this.player.controlBar.controls.playlistToggle.focus();
993
- }
994
- }
995
-
996
- return this.isPanelVisible;
997
- }
998
-
999
- /**
1000
- * Show playlist panel
1001
- */
1002
- showPanel() {
1003
- return this.togglePanel(true);
1004
- }
1005
-
1006
- /**
1007
- * Hide playlist panel
1008
- */
1009
- hidePanel() {
1010
- return this.togglePanel(false);
1011
- }
1012
-
1013
- /**
1014
- * Destroy playlist manager
1015
- */
1016
- destroy() {
1017
- // Remove event listeners
1018
- this.player.off('ended', this.handleTrackEnd);
1019
- this.player.off('error', this.handleTrackError);
1020
-
1021
- // Remove UI
1022
- if (this.trackArtworkElement) {
1023
- this.trackArtworkElement.remove();
1024
- }
1025
-
1026
- if (this.trackInfoElement) {
1027
- this.trackInfoElement.remove();
1028
- }
1029
-
1030
- if (this.playlistPanel) {
1031
- this.playlistPanel.remove();
1032
- }
1033
-
1034
- // Clear data
1035
- this.clear();
1036
- }
1037
- }
1038
-
1039
- export default PlaylistManager;
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
+ import { i18n } from '../i18n/i18n.js';
9
+ import { TimeUtils } from '../utils/TimeUtils.js';
10
+
11
+ // Static counter for unique IDs
12
+ let playlistInstanceCounter = 0;
13
+
14
+ export class PlaylistManager {
15
+ constructor(player, options = {}) {
16
+ this.player = player;
17
+ this.tracks = [];
18
+ this.initialTracks = Array.isArray(options.tracks) ? options.tracks : [];
19
+ this.currentIndex = -1;
20
+
21
+ // Generate unique instance ID for this playlist
22
+ this.instanceId = ++playlistInstanceCounter;
23
+ this.uniqueId = `vidply-playlist-${this.instanceId}`;
24
+
25
+ // Options
26
+ this.options = {
27
+ autoAdvance: options.autoAdvance !== false, // Default true
28
+ autoPlayFirst: options.autoPlayFirst !== false, // Default true - auto-play first track on load
29
+ loop: options.loop || false,
30
+ showPanel: options.showPanel !== false, // Default true
31
+ ...options
32
+ };
33
+
34
+ // UI elements
35
+ this.container = null;
36
+ this.playlistPanel = null;
37
+ this.trackInfoElement = null;
38
+ this.navigationFeedback = null; // Live region for keyboard navigation feedback
39
+ this.isPanelVisible = this.options.showPanel !== false;
40
+
41
+ // Track change guard to prevent cascade of next() calls
42
+ this.isChangingTrack = false;
43
+
44
+ // Bind methods
45
+ this.handleTrackEnd = this.handleTrackEnd.bind(this);
46
+ this.handleTrackError = this.handleTrackError.bind(this);
47
+
48
+ // Register this playlist manager with the player
49
+ this.player.playlistManager = this;
50
+
51
+ // Initialize
52
+ this.init();
53
+
54
+ // Update controls to add playlist buttons
55
+ this.updatePlayerControls();
56
+
57
+ // Load tracks if provided in options (after UI is ready)
58
+ if (this.initialTracks.length > 0) {
59
+ this.loadPlaylist(this.initialTracks);
60
+ }
61
+ }
62
+
63
+ init() {
64
+ // Listen for track end
65
+ this.player.on('ended', this.handleTrackEnd);
66
+ this.player.on('error', this.handleTrackError);
67
+
68
+ // Listen for playback state changes to show/hide playlist in fullscreen
69
+ this.player.on('play', this.handlePlaybackStateChange.bind(this));
70
+ this.player.on('pause', this.handlePlaybackStateChange.bind(this));
71
+ this.player.on('ended', this.handlePlaybackStateChange.bind(this));
72
+ // Use fullscreenchange event which is what the player actually emits
73
+ this.player.on('fullscreenchange', this.handleFullscreenChange.bind(this));
74
+
75
+ // Listen for audio description state changes to update duration displays
76
+ this.player.on('audiodescriptionenabled', this.handleAudioDescriptionChange.bind(this));
77
+ this.player.on('audiodescriptiondisabled', this.handleAudioDescriptionChange.bind(this));
78
+
79
+ // Create UI if needed
80
+ if (this.options.showPanel) {
81
+ this.createUI();
82
+ }
83
+
84
+ // Check for data-playlist attribute on player container (only if tracks weren't provided in options)
85
+ if (this.tracks.length === 0 && this.initialTracks.length === 0) {
86
+ this.loadPlaylistFromAttribute();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Load playlist from data-playlist attribute if present
92
+ */
93
+ loadPlaylistFromAttribute() {
94
+ // Check the original wrapper element for data-playlist
95
+ // Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
96
+ // So we need to go up 3 levels
97
+ if (!this.player.element || !this.player.element.parentElement) {
98
+ console.log('VidPly Playlist: No player element found');
99
+ return;
100
+ }
101
+
102
+ const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
103
+ const playerContainer = videoWrapper.parentElement; // .vidply-player
104
+ const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
105
+
106
+ if (!originalElement) {
107
+ console.log('VidPly Playlist: No original element found');
108
+ return;
109
+ }
110
+
111
+ // Load playlist options from data attributes
112
+ this.loadOptionsFromAttributes(originalElement);
113
+
114
+ const playlistData = originalElement.getAttribute('data-playlist');
115
+ if (!playlistData) {
116
+ console.log('VidPly Playlist: No data-playlist attribute found');
117
+ return;
118
+ }
119
+
120
+ console.log('VidPly Playlist: Found data-playlist attribute, parsing...');
121
+ try {
122
+ const tracks = JSON.parse(playlistData);
123
+ if (Array.isArray(tracks) && tracks.length > 0) {
124
+ console.log(`VidPly Playlist: Loaded ${tracks.length} tracks from data-playlist`);
125
+ this.loadPlaylist(tracks);
126
+ } else {
127
+ console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
128
+ }
129
+ } catch (error) {
130
+ console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Load playlist options from data attributes
136
+ * @param {HTMLElement} element - Element to read attributes from
137
+ */
138
+ loadOptionsFromAttributes(element) {
139
+ // data-playlist-auto-advance
140
+ const autoAdvance = element.getAttribute('data-playlist-auto-advance');
141
+ if (autoAdvance !== null) {
142
+ this.options.autoAdvance = autoAdvance === 'true';
143
+ }
144
+
145
+ // data-playlist-auto-play-first
146
+ const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
147
+ if (autoPlayFirst !== null) {
148
+ this.options.autoPlayFirst = autoPlayFirst === 'true';
149
+ }
150
+
151
+ // data-playlist-loop
152
+ const loop = element.getAttribute('data-playlist-loop');
153
+ if (loop !== null) {
154
+ this.options.loop = loop === 'true';
155
+ }
156
+
157
+ // data-playlist-show-panel
158
+ const showPanel = element.getAttribute('data-playlist-show-panel');
159
+ if (showPanel !== null) {
160
+ this.options.showPanel = showPanel === 'true';
161
+ }
162
+
163
+ console.log('VidPly Playlist: Options from attributes:', this.options);
164
+ }
165
+
166
+ /**
167
+ * Update player controls to add playlist navigation buttons
168
+ */
169
+ updatePlayerControls() {
170
+ if (!this.player.controlBar) return;
171
+
172
+ const controlBar = this.player.controlBar;
173
+
174
+ // Clear existing controls content (except the element itself)
175
+ controlBar.element.innerHTML = '';
176
+
177
+ // Recreate controls with playlist buttons now available
178
+ controlBar.createControls();
179
+
180
+ // Reattach events for the new controls
181
+ controlBar.attachEvents();
182
+ controlBar.setupAutoHide();
183
+ }
184
+
185
+ /**
186
+ * Load a playlist
187
+ * @param {Array} tracks - Array of track objects
188
+ */
189
+ loadPlaylist(tracks) {
190
+ this.tracks = tracks;
191
+ this.currentIndex = -1;
192
+
193
+ // Add playlist class to container
194
+ if (this.container) {
195
+ this.container.classList.add('vidply-has-playlist');
196
+ }
197
+
198
+ // Update UI
199
+ if (this.playlistPanel) {
200
+ this.renderPlaylist();
201
+ }
202
+
203
+ // Auto-play first track (if enabled)
204
+ if (tracks.length > 0) {
205
+ if (this.options.autoPlayFirst) {
206
+ this.play(0);
207
+ } else {
208
+ // Load first track without playing
209
+ this.loadTrack(0);
210
+ }
211
+ }
212
+
213
+ // Update visibility based on current state
214
+ this.updatePlaylistVisibilityInFullscreen();
215
+ }
216
+
217
+ /**
218
+ * Load a track without playing
219
+ * @param {number} index - Track index
220
+ */
221
+ loadTrack(index) {
222
+ if (index < 0 || index >= this.tracks.length) {
223
+ console.warn('VidPly Playlist: Invalid track index', index);
224
+ return;
225
+ }
226
+
227
+ const track = this.tracks[index];
228
+
229
+ // Set guard flag to prevent cascade of next() calls during track change
230
+ this.isChangingTrack = true;
231
+
232
+ // Update current index
233
+ this.currentIndex = index;
234
+
235
+ // Load track into player
236
+ this.player.load({
237
+ src: track.src,
238
+ type: track.type,
239
+ poster: track.poster,
240
+ tracks: track.tracks || [],
241
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
242
+ signLanguageSrc: track.signLanguageSrc || null
243
+ });
244
+
245
+ // Update UI
246
+ this.updateTrackInfo(track);
247
+ this.updatePlaylistUI();
248
+
249
+ // Emit event
250
+ this.player.emit('playlisttrackchange', {
251
+ index: index,
252
+ item: track,
253
+ total: this.tracks.length
254
+ });
255
+
256
+ // Clear guard flag after a short delay to ensure track is loaded
257
+ setTimeout(() => {
258
+ this.isChangingTrack = false;
259
+ }, 150);
260
+ }
261
+
262
+ /**
263
+ * Play a specific track
264
+ * @param {number} index - Track index
265
+ * @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
266
+ */
267
+ play(index, userInitiated = false) {
268
+ if (index < 0 || index >= this.tracks.length) {
269
+ console.warn('VidPly Playlist: Invalid track index', index);
270
+ return;
271
+ }
272
+
273
+ const track = this.tracks[index];
274
+
275
+ // Set guard flag to prevent cascade of next() calls during track change
276
+ this.isChangingTrack = true;
277
+
278
+ // Update current index
279
+ this.currentIndex = index;
280
+
281
+ // Load track into player
282
+ this.player.load({
283
+ src: track.src,
284
+ type: track.type,
285
+ poster: track.poster,
286
+ tracks: track.tracks || [],
287
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
288
+ signLanguageSrc: track.signLanguageSrc || null
289
+ });
290
+
291
+ // Update UI
292
+ this.updateTrackInfo(track);
293
+ this.updatePlaylistUI();
294
+
295
+ // Emit event
296
+ this.player.emit('playlisttrackchange', {
297
+ index: index,
298
+ item: track,
299
+ total: this.tracks.length
300
+ });
301
+
302
+ // Auto-play and clear guard flag after playback starts
303
+ setTimeout(() => {
304
+ this.player.play();
305
+ // Clear guard flag after a short delay to ensure track has started
306
+ setTimeout(() => {
307
+ this.isChangingTrack = false;
308
+ }, 50);
309
+ }, 100);
310
+ }
311
+
312
+ /**
313
+ * Play next track
314
+ */
315
+ next() {
316
+ let nextIndex = this.currentIndex + 1;
317
+
318
+ if (nextIndex >= this.tracks.length) {
319
+ if (this.options.loop) {
320
+ nextIndex = 0;
321
+ } else {
322
+ return;
323
+ }
324
+ }
325
+
326
+ this.play(nextIndex);
327
+ }
328
+
329
+ /**
330
+ * Play previous track
331
+ */
332
+ previous() {
333
+ let prevIndex = this.currentIndex - 1;
334
+
335
+ if (prevIndex < 0) {
336
+ if (this.options.loop) {
337
+ prevIndex = this.tracks.length - 1;
338
+ } else {
339
+ return;
340
+ }
341
+ }
342
+
343
+ this.play(prevIndex);
344
+ }
345
+
346
+ /**
347
+ * Handle track end
348
+ */
349
+ handleTrackEnd() {
350
+ // Don't auto-advance if we're already in the process of changing tracks
351
+ // This prevents a cascade of next() calls when loading a new track triggers an 'ended' event
352
+ if (this.isChangingTrack) {
353
+ return;
354
+ }
355
+
356
+ if (this.options.autoAdvance) {
357
+ this.next();
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Handle track error
363
+ */
364
+ handleTrackError(e) {
365
+ console.error('VidPly Playlist: Track error', e);
366
+
367
+ // Try next track
368
+ if (this.options.autoAdvance) {
369
+ setTimeout(() => {
370
+ this.next();
371
+ }, 1000);
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Handle playback state changes (for fullscreen playlist visibility)
377
+ */
378
+ handlePlaybackStateChange() {
379
+ this.updatePlaylistVisibilityInFullscreen();
380
+ }
381
+
382
+ /**
383
+ * Handle fullscreen state changes
384
+ */
385
+ handleFullscreenChange() {
386
+ // Use a small delay to ensure fullscreen state is fully applied
387
+ setTimeout(() => {
388
+ this.updatePlaylistVisibilityInFullscreen();
389
+ }, 50);
390
+ }
391
+
392
+ /**
393
+ * Handle audio description state changes
394
+ * Updates duration displays to show audio-described version duration when AD is enabled
395
+ */
396
+ handleAudioDescriptionChange() {
397
+ const currentTrack = this.getCurrentTrack();
398
+ if (!currentTrack) return;
399
+
400
+ // Update the track info display with the appropriate duration
401
+ this.updateTrackInfo(currentTrack);
402
+
403
+ // Update the playlist UI to reflect duration changes (aria-labels)
404
+ this.updatePlaylistUI();
405
+
406
+ // Update visual duration elements in playlist panel
407
+ this.updatePlaylistDurations();
408
+ }
409
+
410
+ /**
411
+ * Update the visual duration displays in the playlist panel
412
+ * Called when audio description state changes
413
+ */
414
+ updatePlaylistDurations() {
415
+ if (!this.playlistPanel) return;
416
+
417
+ const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
418
+
419
+ items.forEach((item, index) => {
420
+ const track = this.tracks[index];
421
+ if (!track) return;
422
+
423
+ const effectiveDuration = this.getEffectiveDuration(track);
424
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
425
+
426
+ // Update duration badge on thumbnail (if exists)
427
+ const durationBadge = item.querySelector('.vidply-playlist-duration-badge');
428
+ if (durationBadge) {
429
+ durationBadge.textContent = trackDuration;
430
+ }
431
+
432
+ // Update inline duration (if exists)
433
+ const inlineDuration = item.querySelector('.vidply-playlist-item-duration');
434
+ if (inlineDuration) {
435
+ inlineDuration.textContent = trackDuration;
436
+ }
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Get the effective duration for a track based on audio description state
442
+ * @param {Object} track - Track object
443
+ * @returns {number|null} - Duration in seconds or null if not available
444
+ */
445
+ getEffectiveDuration(track) {
446
+ if (!track) return null;
447
+
448
+ const isAudioDescriptionEnabled = this.player.state.audioDescriptionEnabled;
449
+
450
+ // If audio description is enabled and track has audioDescriptionDuration, use it
451
+ if (isAudioDescriptionEnabled && track.audioDescriptionDuration) {
452
+ return track.audioDescriptionDuration;
453
+ }
454
+
455
+ // Otherwise use regular duration
456
+ return track.duration || null;
457
+ }
458
+
459
+ /**
460
+ * Update playlist visibility based on fullscreen and playback state
461
+ * In fullscreen: show when paused/not started, hide when playing
462
+ * Outside fullscreen: respect original panel visibility setting
463
+ */
464
+ updatePlaylistVisibilityInFullscreen() {
465
+ if (!this.playlistPanel || !this.tracks.length) return;
466
+
467
+ const isFullscreen = this.player.state.fullscreen;
468
+ const isPlaying = this.player.state.playing;
469
+
470
+ if (isFullscreen) {
471
+ // In fullscreen: show only when not playing (paused or not started)
472
+ // Check playing state explicitly since paused might not be set initially
473
+ if (!isPlaying) {
474
+ this.playlistPanel.classList.add('vidply-playlist-fullscreen-visible');
475
+ this.playlistPanel.style.display = 'block';
476
+ } else {
477
+ this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
478
+ // Add a smooth fade out with delay to match CSS transition
479
+ setTimeout(() => {
480
+ // Double-check state hasn't changed before hiding
481
+ if (this.player.state.playing && this.player.state.fullscreen) {
482
+ this.playlistPanel.style.display = 'none';
483
+ }
484
+ }, 300); // Match CSS transition duration
485
+ }
486
+ } else {
487
+ // Outside fullscreen: restore original behavior
488
+ this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
489
+ if (this.isPanelVisible && this.tracks.length > 0) {
490
+ this.playlistPanel.style.display = 'block';
491
+ } else {
492
+ this.playlistPanel.style.display = 'none';
493
+ }
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Create playlist UI
499
+ */
500
+ createUI() {
501
+ // Find player container
502
+ this.container = this.player.container;
503
+
504
+ if (!this.container) {
505
+ console.warn('VidPly Playlist: No container found');
506
+ return;
507
+ }
508
+
509
+ // Create track artwork element (shows album art/poster for audio playlists)
510
+ // Only create for audio players
511
+ if (this.player.element.tagName === 'AUDIO') {
512
+ this.trackArtworkElement = DOMUtils.createElement('div', {
513
+ className: 'vidply-track-artwork',
514
+ attributes: {
515
+ 'aria-hidden': 'true'
516
+ }
517
+ });
518
+ this.trackArtworkElement.style.display = 'none';
519
+
520
+ // Insert before video wrapper
521
+ const videoWrapper = this.container.querySelector('.vidply-video-wrapper');
522
+ if (videoWrapper) {
523
+ this.container.insertBefore(this.trackArtworkElement, videoWrapper);
524
+ } else {
525
+ this.container.appendChild(this.trackArtworkElement);
526
+ }
527
+ }
528
+
529
+ // Create track info element (shows current track)
530
+ this.trackInfoElement = DOMUtils.createElement('div', {
531
+ className: 'vidply-track-info',
532
+ attributes: {
533
+ role: 'status'
534
+ }
535
+ });
536
+ this.trackInfoElement.style.display = 'none';
537
+
538
+ this.container.appendChild(this.trackInfoElement);
539
+
540
+ // Create navigation feedback live region
541
+ this.navigationFeedback = DOMUtils.createElement('div', {
542
+ className: 'vidply-sr-only',
543
+ attributes: {
544
+ role: 'status',
545
+ 'aria-live': 'polite',
546
+ 'aria-atomic': 'true'
547
+ }
548
+ });
549
+ this.container.appendChild(this.navigationFeedback);
550
+
551
+ // Create playlist panel with proper landmark
552
+ this.playlistPanel = DOMUtils.createElement('div', {
553
+ className: 'vidply-playlist-panel',
554
+ attributes: {
555
+ id: `${this.uniqueId}-panel`,
556
+ role: 'region',
557
+ 'aria-label': i18n.t('playlist.title'),
558
+ 'aria-labelledby': `${this.uniqueId}-heading`
559
+ }
560
+ });
561
+ this.playlistPanel.style.display = this.isPanelVisible ? 'none' : 'none'; // Will be shown when playlist is loaded
562
+
563
+ this.container.appendChild(this.playlistPanel);
564
+ }
565
+
566
+ /**
567
+ * Update track info display
568
+ */
569
+ updateTrackInfo(track) {
570
+ if (!this.trackInfoElement) return;
571
+
572
+ const trackNumber = this.currentIndex + 1;
573
+ const totalTracks = this.tracks.length;
574
+ const trackTitle = track.title || i18n.t('playlist.untitled');
575
+ const trackArtist = track.artist || '';
576
+
577
+ // Use effective duration (audio description duration when AD is enabled)
578
+ const effectiveDuration = this.getEffectiveDuration(track);
579
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
580
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
581
+
582
+ // Screen reader announcement - include duration if available
583
+ const artistPart = trackArtist ? i18n.t('playlist.by') + trackArtist : '';
584
+ const durationPart = trackDurationReadable ? `. ${trackDurationReadable}` : '';
585
+ const announcement = i18n.t('playlist.nowPlaying', {
586
+ current: trackNumber,
587
+ total: totalTracks,
588
+ title: trackTitle,
589
+ artist: artistPart
590
+ }) + durationPart;
591
+
592
+ const trackOfText = i18n.t('playlist.trackOf', {
593
+ current: trackNumber,
594
+ total: totalTracks
595
+ });
596
+
597
+ // Build duration HTML if available
598
+ const durationHtml = trackDuration
599
+ ? `<span class="vidply-track-duration" aria-hidden="true">${DOMUtils.escapeHTML(trackDuration)}</span>`
600
+ : '';
601
+
602
+ // Get description if available
603
+ const trackDescription = track.description || '';
604
+
605
+ this.trackInfoElement.innerHTML = `
606
+ <span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
607
+ <div class="vidply-track-header" aria-hidden="true">
608
+ <span class="vidply-track-number">${DOMUtils.escapeHTML(trackOfText)}</span>
609
+ ${durationHtml}
610
+ </div>
611
+ <div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
612
+ ${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
613
+ ${trackDescription ? `<div class="vidply-track-description" aria-hidden="true">${DOMUtils.escapeHTML(trackDescription)}</div>` : ''}
614
+ `;
615
+
616
+ this.trackInfoElement.style.display = 'block';
617
+
618
+ // Update track artwork if available (for audio playlists)
619
+ this.updateTrackArtwork(track);
620
+ }
621
+
622
+ /**
623
+ * Update track artwork display (for audio playlists)
624
+ */
625
+ updateTrackArtwork(track) {
626
+ if (!this.trackArtworkElement) return;
627
+
628
+ // If track has a poster/artwork, show it
629
+ if (track.poster) {
630
+ this.trackArtworkElement.style.backgroundImage = `url(${track.poster})`;
631
+ this.trackArtworkElement.style.display = 'block';
632
+ } else {
633
+ // No artwork available, hide the element
634
+ this.trackArtworkElement.style.display = 'none';
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Render playlist
640
+ */
641
+ renderPlaylist() {
642
+ if (!this.playlistPanel) return;
643
+
644
+ // Clear existing
645
+ this.playlistPanel.innerHTML = '';
646
+
647
+ // Create header
648
+ const header = DOMUtils.createElement('h2', {
649
+ className: 'vidply-playlist-header',
650
+ attributes: {
651
+ id: `${this.uniqueId}-heading`
652
+ }
653
+ });
654
+ header.textContent = `${i18n.t('playlist.title')} (${this.tracks.length})`;
655
+ this.playlistPanel.appendChild(header);
656
+
657
+ // Add keyboard instructions (visually hidden)
658
+ const instructions = DOMUtils.createElement('div', {
659
+ className: 'vidply-sr-only',
660
+ attributes: {
661
+ id: `${this.uniqueId}-keyboard-instructions`
662
+ }
663
+ });
664
+ instructions.textContent = 'Playlist navigation: Use Up and Down arrow keys to move between tracks. Press Page Up or Page Down to skip 5 tracks. Press Home to go to first track, End to go to last track. Press Enter or Space to play the selected track.';
665
+ this.playlistPanel.appendChild(instructions);
666
+
667
+ // Create list (proper ul element)
668
+ const list = DOMUtils.createElement('ul', {
669
+ className: 'vidply-playlist-list',
670
+ attributes: {
671
+ role: 'listbox',
672
+ 'aria-labelledby': `${this.uniqueId}-heading`,
673
+ 'aria-describedby': `${this.uniqueId}-keyboard-instructions`
674
+ }
675
+ });
676
+
677
+ this.tracks.forEach((track, index) => {
678
+ const item = this.createPlaylistItem(track, index);
679
+ list.appendChild(item);
680
+ });
681
+
682
+ this.playlistPanel.appendChild(list);
683
+
684
+ // Show panel if it should be visible
685
+ if (this.isPanelVisible) {
686
+ this.playlistPanel.style.display = 'block';
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Create playlist item element
692
+ */
693
+ createPlaylistItem(track, index) {
694
+ const trackPosition = i18n.t('playlist.trackOf', {
695
+ current: index + 1,
696
+ total: this.tracks.length
697
+ });
698
+ const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
699
+ const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
700
+
701
+ // Use effective duration (audio description duration when AD is enabled)
702
+ const effectiveDuration = this.getEffectiveDuration(track);
703
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
704
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
705
+ const isActive = index === this.currentIndex;
706
+
707
+ // Build accessible label for screen readers
708
+ // With role="option" and aria-checked, screen reader will announce selection state
709
+ // Position is already announced via aria-posinset/aria-setsize
710
+ // Format: "Title by Artist. 3 minutes, 45 seconds."
711
+ let ariaLabel = `${trackTitle}${trackArtist}`;
712
+ if (trackDurationReadable) {
713
+ ariaLabel += `. ${trackDurationReadable}`;
714
+ }
715
+
716
+ // Create list item container (semantic HTML)
717
+ const item = DOMUtils.createElement('li', {
718
+ className: isActive ? 'vidply-playlist-item vidply-playlist-item-active' : 'vidply-playlist-item',
719
+ attributes: {
720
+ 'data-playlist-index': index
721
+ }
722
+ });
723
+
724
+ // Create button wrapper for interactive content
725
+ const button = DOMUtils.createElement('button', {
726
+ className: 'vidply-playlist-item-button',
727
+ attributes: {
728
+ type: 'button',
729
+ role: 'option',
730
+ tabIndex: index === 0 ? 0 : -1, // Only first item is in tab order initially
731
+ 'aria-label': ariaLabel,
732
+ 'aria-posinset': index + 1,
733
+ 'aria-setsize': this.tracks.length,
734
+ 'aria-checked': isActive ? 'true' : 'false'
735
+ }
736
+ });
737
+
738
+ // Add aria-current if active
739
+ if (isActive) {
740
+ button.setAttribute('aria-current', 'true');
741
+ button.setAttribute('tabIndex', '0'); // Active item should always be tabbable
742
+ }
743
+
744
+ // Thumbnail container with optional duration badge
745
+ const thumbnailContainer = DOMUtils.createElement('span', {
746
+ className: 'vidply-playlist-thumbnail-container',
747
+ attributes: {
748
+ 'aria-hidden': 'true'
749
+ }
750
+ });
751
+
752
+ // Thumbnail or icon
753
+ const thumbnail = DOMUtils.createElement('span', {
754
+ className: 'vidply-playlist-thumbnail'
755
+ });
756
+
757
+ if (track.poster) {
758
+ thumbnail.style.backgroundImage = `url(${track.poster})`;
759
+ } else {
760
+ // Show music/speaker icon for audio tracks
761
+ const icon = createIconElement('music');
762
+ icon.classList.add('vidply-playlist-thumbnail-icon');
763
+ thumbnail.appendChild(icon);
764
+ }
765
+
766
+ thumbnailContainer.appendChild(thumbnail);
767
+
768
+ // Duration badge on thumbnail (like YouTube) - only show if there's a poster
769
+ if (trackDuration && track.poster) {
770
+ const durationBadge = DOMUtils.createElement('span', {
771
+ className: 'vidply-playlist-duration-badge'
772
+ });
773
+ durationBadge.textContent = trackDuration;
774
+ thumbnailContainer.appendChild(durationBadge);
775
+ }
776
+
777
+ button.appendChild(thumbnailContainer);
778
+
779
+ // Info section (title, artist, description)
780
+ const info = DOMUtils.createElement('span', {
781
+ className: 'vidply-playlist-item-info',
782
+ attributes: {
783
+ 'aria-hidden': 'true'
784
+ }
785
+ });
786
+
787
+ // Title row with optional inline duration (for when no thumbnail)
788
+ const titleRow = DOMUtils.createElement('span', {
789
+ className: 'vidply-playlist-item-title-row'
790
+ });
791
+
792
+ const title = DOMUtils.createElement('span', {
793
+ className: 'vidply-playlist-item-title'
794
+ });
795
+ title.textContent = trackTitle;
796
+ titleRow.appendChild(title);
797
+
798
+ // Inline duration (shown when no poster/thumbnail)
799
+ if (trackDuration && !track.poster) {
800
+ const inlineDuration = DOMUtils.createElement('span', {
801
+ className: 'vidply-playlist-item-duration'
802
+ });
803
+ inlineDuration.textContent = trackDuration;
804
+ titleRow.appendChild(inlineDuration);
805
+ }
806
+
807
+ info.appendChild(titleRow);
808
+
809
+ // Artist
810
+ if (track.artist) {
811
+ const artist = DOMUtils.createElement('span', {
812
+ className: 'vidply-playlist-item-artist'
813
+ });
814
+ artist.textContent = track.artist;
815
+ info.appendChild(artist);
816
+ }
817
+
818
+ // Description (truncated)
819
+ if (track.description) {
820
+ const description = DOMUtils.createElement('span', {
821
+ className: 'vidply-playlist-item-description'
822
+ });
823
+ description.textContent = track.description;
824
+ info.appendChild(description);
825
+ }
826
+
827
+ button.appendChild(info);
828
+
829
+ // Play icon
830
+ const playIcon = createIconElement('play');
831
+ playIcon.classList.add('vidply-playlist-item-icon');
832
+ playIcon.setAttribute('aria-hidden', 'true');
833
+ button.appendChild(playIcon);
834
+
835
+ // Click handler
836
+ button.addEventListener('click', () => {
837
+ this.play(index, true); // User-initiated
838
+ });
839
+
840
+ // Keyboard handler
841
+ button.addEventListener('keydown', (e) => {
842
+ this.handlePlaylistItemKeydown(e, index);
843
+ });
844
+
845
+ // Append button to list item
846
+ item.appendChild(button);
847
+
848
+ return item;
849
+ }
850
+
851
+ /**
852
+ * Handle keyboard navigation in playlist items
853
+ */
854
+ handlePlaylistItemKeydown(e, index) {
855
+ const buttons = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item-button'));
856
+ let newIndex = -1;
857
+ let announcement = '';
858
+
859
+ switch(e.key) {
860
+ case 'Enter':
861
+ case ' ':
862
+ e.preventDefault();
863
+ e.stopPropagation();
864
+ this.play(index, true); // User-initiated
865
+ return; // No need to move focus
866
+
867
+ case 'ArrowDown':
868
+ e.preventDefault();
869
+ e.stopPropagation();
870
+ // Move to next item
871
+ if (index < buttons.length - 1) {
872
+ newIndex = index + 1;
873
+ } else {
874
+ // At the end, announce boundary
875
+ announcement = `End of playlist. ${buttons.length} of ${buttons.length}.`;
876
+ }
877
+ break;
878
+
879
+ case 'ArrowUp':
880
+ e.preventDefault();
881
+ e.stopPropagation();
882
+ // Move to previous item
883
+ if (index > 0) {
884
+ newIndex = index - 1;
885
+ } else {
886
+ // At the beginning, announce boundary
887
+ announcement = 'Beginning of playlist. 1 of ' + buttons.length + '.';
888
+ }
889
+ break;
890
+
891
+ case 'PageDown':
892
+ e.preventDefault();
893
+ e.stopPropagation();
894
+ // Move 5 items down (or to end)
895
+ newIndex = Math.min(index + 5, buttons.length - 1);
896
+ if (newIndex === buttons.length - 1 && index !== newIndex) {
897
+ announcement = `Jumped to last track. ${newIndex + 1} of ${buttons.length}.`;
898
+ }
899
+ break;
900
+
901
+ case 'PageUp':
902
+ e.preventDefault();
903
+ e.stopPropagation();
904
+ // Move 5 items up (or to beginning)
905
+ newIndex = Math.max(index - 5, 0);
906
+ if (newIndex === 0 && index !== newIndex) {
907
+ announcement = `Jumped to first track. 1 of ${buttons.length}.`;
908
+ }
909
+ break;
910
+
911
+ case 'Home':
912
+ e.preventDefault();
913
+ e.stopPropagation();
914
+ // Move to first item
915
+ newIndex = 0;
916
+ if (index !== 0) {
917
+ announcement = `First track. 1 of ${buttons.length}.`;
918
+ }
919
+ break;
920
+
921
+ case 'End':
922
+ e.preventDefault();
923
+ e.stopPropagation();
924
+ // Move to last item
925
+ newIndex = buttons.length - 1;
926
+ if (index !== buttons.length - 1) {
927
+ announcement = `Last track. ${buttons.length} of ${buttons.length}.`;
928
+ }
929
+ break;
930
+ }
931
+
932
+ // Update tab indices for roving tabindex pattern
933
+ if (newIndex !== -1 && newIndex !== index) {
934
+ buttons[index].setAttribute('tabIndex', '-1');
935
+ buttons[newIndex].setAttribute('tabIndex', '0');
936
+ buttons[newIndex].focus({ preventScroll: false });
937
+
938
+ // Scroll the focused item into view (same behavior as mouse interaction)
939
+ const item = buttons[newIndex].closest('.vidply-playlist-item');
940
+ if (item) {
941
+ item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
942
+ }
943
+ }
944
+
945
+ // Announce navigation feedback
946
+ if (announcement && this.navigationFeedback) {
947
+ this.navigationFeedback.textContent = announcement;
948
+ // Clear after a short delay to allow for repeated announcements
949
+ setTimeout(() => {
950
+ if (this.navigationFeedback) {
951
+ this.navigationFeedback.textContent = '';
952
+ }
953
+ }, 1000);
954
+ }
955
+ }
956
+
957
+ /**
958
+ * Update playlist UI (highlight current track)
959
+ */
960
+ updatePlaylistUI() {
961
+ if (!this.playlistPanel) return;
962
+
963
+ const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
964
+ const buttons = this.playlistPanel.querySelectorAll('.vidply-playlist-item-button');
965
+
966
+ items.forEach((item, index) => {
967
+ const button = buttons[index];
968
+ if (!button) return;
969
+
970
+ const track = this.tracks[index];
971
+ const trackPosition = i18n.t('playlist.trackOf', {
972
+ current: index + 1,
973
+ total: this.tracks.length
974
+ });
975
+ const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
976
+ const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
977
+
978
+ // Use effective duration (audio description duration when AD is enabled)
979
+ const effectiveDuration = this.getEffectiveDuration(track);
980
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
981
+
982
+ if (index === this.currentIndex) {
983
+ // Update list item styling
984
+ item.classList.add('vidply-playlist-item-active');
985
+
986
+ // Update button ARIA attributes
987
+ button.setAttribute('aria-current', 'true');
988
+ button.setAttribute('aria-checked', 'true');
989
+ button.setAttribute('tabIndex', '0'); // Active item should be tabbable
990
+
991
+ // Simplified aria-label - status and actions are announced via ARIA roles
992
+ let ariaLabel = `${trackTitle}${trackArtist}`;
993
+ if (trackDurationReadable) {
994
+ ariaLabel += `. ${trackDurationReadable}`;
995
+ }
996
+ button.setAttribute('aria-label', ariaLabel);
997
+
998
+ // Scroll into view within playlist panel (uses 'nearest' to minimize page scroll)
999
+ item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1000
+ } else {
1001
+ // Update list item styling
1002
+ item.classList.remove('vidply-playlist-item-active');
1003
+
1004
+ // Update button ARIA attributes
1005
+ button.removeAttribute('aria-current');
1006
+ button.setAttribute('aria-checked', 'false');
1007
+ button.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
1008
+
1009
+ // Simplified aria-label - status and actions are announced via ARIA roles
1010
+ let ariaLabel = `${trackTitle}${trackArtist}`;
1011
+ if (trackDurationReadable) {
1012
+ ariaLabel += `. ${trackDurationReadable}`;
1013
+ }
1014
+ button.setAttribute('aria-label', ariaLabel);
1015
+ }
1016
+ });
1017
+ }
1018
+
1019
+ /**
1020
+ * Get current track
1021
+ */
1022
+ getCurrentTrack() {
1023
+ return this.tracks[this.currentIndex] || null;
1024
+ }
1025
+
1026
+ /**
1027
+ * Get playlist info
1028
+ */
1029
+ getPlaylistInfo() {
1030
+ return {
1031
+ currentIndex: this.currentIndex,
1032
+ totalTracks: this.tracks.length,
1033
+ currentTrack: this.getCurrentTrack(),
1034
+ hasNext: this.hasNext(),
1035
+ hasPrevious: this.hasPrevious()
1036
+ };
1037
+ }
1038
+
1039
+ /**
1040
+ * Check if there is a next track
1041
+ */
1042
+ hasNext() {
1043
+ if (this.options.loop) return true;
1044
+ return this.currentIndex < this.tracks.length - 1;
1045
+ }
1046
+
1047
+ /**
1048
+ * Check if there is a previous track
1049
+ */
1050
+ hasPrevious() {
1051
+ if (this.options.loop) return true;
1052
+ return this.currentIndex > 0;
1053
+ }
1054
+
1055
+ /**
1056
+ * Add track to playlist
1057
+ */
1058
+ addTrack(track) {
1059
+ this.tracks.push(track);
1060
+
1061
+ if (this.playlistPanel) {
1062
+ this.renderPlaylist();
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Remove track from playlist
1068
+ */
1069
+ removeTrack(index) {
1070
+ if (index < 0 || index >= this.tracks.length) return;
1071
+
1072
+ this.tracks.splice(index, 1);
1073
+
1074
+ // Adjust current index if needed
1075
+ if (index < this.currentIndex) {
1076
+ this.currentIndex--;
1077
+ } else if (index === this.currentIndex) {
1078
+ // Current track was removed, play next or stop
1079
+ if (this.currentIndex >= this.tracks.length) {
1080
+ this.currentIndex = this.tracks.length - 1;
1081
+ }
1082
+
1083
+ if (this.currentIndex >= 0) {
1084
+ this.play(this.currentIndex);
1085
+ }
1086
+ }
1087
+
1088
+ if (this.playlistPanel) {
1089
+ this.renderPlaylist();
1090
+ }
1091
+ }
1092
+
1093
+ /**
1094
+ * Clear playlist
1095
+ */
1096
+ clear() {
1097
+ this.tracks = [];
1098
+ this.currentIndex = -1;
1099
+
1100
+ if (this.playlistPanel) {
1101
+ this.playlistPanel.innerHTML = '';
1102
+ this.playlistPanel.style.display = 'none';
1103
+ }
1104
+
1105
+ if (this.trackInfoElement) {
1106
+ this.trackInfoElement.innerHTML = '';
1107
+ this.trackInfoElement.style.display = 'none';
1108
+ }
1109
+
1110
+ if (this.trackArtworkElement) {
1111
+ this.trackArtworkElement.style.backgroundImage = '';
1112
+ this.trackArtworkElement.style.display = 'none';
1113
+ }
1114
+ }
1115
+
1116
+ /**
1117
+ * Toggle playlist panel visibility
1118
+ * @param {boolean} show - Optional: force show (true) or hide (false)
1119
+ * @returns {boolean} - New visibility state
1120
+ */
1121
+ togglePanel(show) {
1122
+ if (!this.playlistPanel) return false;
1123
+
1124
+ // Determine new state
1125
+ const shouldShow = show !== undefined ? show : this.playlistPanel.style.display === 'none';
1126
+
1127
+ if (shouldShow) {
1128
+ this.playlistPanel.style.display = 'block';
1129
+ this.isPanelVisible = true;
1130
+
1131
+ // Focus first item if playlist has tracks
1132
+ if (this.tracks.length > 0) {
1133
+ setTimeout(() => {
1134
+ const firstItem = this.playlistPanel.querySelector('.vidply-playlist-item[tabindex="0"]');
1135
+ if (firstItem) {
1136
+ firstItem.focus({ preventScroll: true });
1137
+ }
1138
+ }, 100);
1139
+ }
1140
+
1141
+ // Update toggle button state if it exists
1142
+ if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1143
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'true');
1144
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'true');
1145
+ }
1146
+ } else {
1147
+ this.playlistPanel.style.display = 'none';
1148
+ this.isPanelVisible = false;
1149
+
1150
+ // Update toggle button state if it exists
1151
+ if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1152
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'false');
1153
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'false');
1154
+
1155
+ // Return focus to toggle button
1156
+ this.player.controlBar.controls.playlistToggle.focus({ preventScroll: true });
1157
+ }
1158
+ }
1159
+
1160
+ return this.isPanelVisible;
1161
+ }
1162
+
1163
+ /**
1164
+ * Show playlist panel
1165
+ */
1166
+ showPanel() {
1167
+ return this.togglePanel(true);
1168
+ }
1169
+
1170
+ /**
1171
+ * Hide playlist panel
1172
+ */
1173
+ hidePanel() {
1174
+ return this.togglePanel(false);
1175
+ }
1176
+
1177
+ /**
1178
+ * Destroy playlist manager
1179
+ */
1180
+ destroy() {
1181
+ // Remove event listeners
1182
+ this.player.off('ended', this.handleTrackEnd);
1183
+ this.player.off('error', this.handleTrackError);
1184
+
1185
+ // Remove UI
1186
+ if (this.trackArtworkElement) {
1187
+ this.trackArtworkElement.remove();
1188
+ }
1189
+
1190
+ if (this.trackInfoElement) {
1191
+ this.trackInfoElement.remove();
1192
+ }
1193
+
1194
+ if (this.playlistPanel) {
1195
+ this.playlistPanel.remove();
1196
+ }
1197
+
1198
+ // Clear data
1199
+ this.clear();
1200
+ }
1201
+ }
1202
+
1203
+ export default PlaylistManager;