vidply 1.0.21 → 1.0.24

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