vidply 1.0.31 → 1.0.32

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,1511 +1,1511 @@
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
- recreatePlayers: options.recreatePlayers || false, // New: recreate player for each track type
32
- ...options
33
- };
34
-
35
- // UI elements
36
- this.container = null;
37
- this.playlistPanel = null;
38
- this.trackInfoElement = null;
39
- this.navigationFeedback = null; // Live region for keyboard navigation feedback
40
- this.isPanelVisible = this.options.showPanel !== false;
41
-
42
- // Track change guard to prevent cascade of next() calls
43
- this.isChangingTrack = false;
44
-
45
- // Store the host element for player recreation
46
- this.hostElement = options.hostElement || null;
47
- this.PlayerClass = options.PlayerClass || null;
48
-
49
- // Bind methods
50
- this.handleTrackEnd = this.handleTrackEnd.bind(this);
51
- this.handleTrackError = this.handleTrackError.bind(this);
52
-
53
- // Register this playlist manager with the player
54
- this.player.playlistManager = this;
55
-
56
- // Initialize
57
- this.init();
58
-
59
- // Update controls to add playlist buttons
60
- this.updatePlayerControls();
61
-
62
- // Load tracks if provided in options (after UI is ready)
63
- if (this.initialTracks.length > 0) {
64
- this.loadPlaylist(this.initialTracks);
65
- }
66
- }
67
-
68
- /**
69
- * Determine the media type for a track
70
- * @param {Object} track - Track object
71
- * @returns {string} - 'audio', 'video', 'youtube', 'vimeo', 'soundcloud', 'hls'
72
- */
73
- getTrackMediaType(track) {
74
- const src = track.src || '';
75
-
76
- if (src.includes('youtube.com') || src.includes('youtu.be')) {
77
- return 'youtube';
78
- }
79
- if (src.includes('vimeo.com')) {
80
- return 'vimeo';
81
- }
82
- if (src.includes('soundcloud.com') || src.includes('api.soundcloud.com')) {
83
- return 'soundcloud';
84
- }
85
- if (src.includes('.m3u8')) {
86
- return 'hls';
87
- }
88
- if (track.type && track.type.startsWith('audio/')) {
89
- return 'audio';
90
- }
91
- // Default to video for video types or unknown
92
- return 'video';
93
- }
94
-
95
- /**
96
- * Recreate the player with the appropriate element type for the track
97
- * @param {Object} track - Track to load
98
- * @param {boolean} autoPlay - Whether to auto-play after creation
99
- */
100
- async recreatePlayerForTrack(track, autoPlay = false) {
101
- if (!this.hostElement || !this.PlayerClass) {
102
- console.warn('VidPly Playlist: Cannot recreate player - missing hostElement or PlayerClass');
103
- return false;
104
- }
105
-
106
- const mediaType = this.getTrackMediaType(track);
107
- // SoundCloud uses an iframe widget, so it doesn't need an audio element
108
- // Only local audio files need an actual <audio> element
109
- const elementType = (mediaType === 'audio') ? 'audio' : 'video';
110
-
111
- // Store playlist panel state
112
- const wasVisible = this.isPanelVisible;
113
- const savedTracks = [...this.tracks]; // Keep track data
114
- const savedIndex = this.currentIndex;
115
-
116
- // Detach all playlist UI elements from DOM (keep references)
117
- // These will be reattached to the new player container
118
- if (this.trackArtworkElement && this.trackArtworkElement.parentNode) {
119
- this.trackArtworkElement.parentNode.removeChild(this.trackArtworkElement);
120
- }
121
- if (this.trackInfoElement && this.trackInfoElement.parentNode) {
122
- this.trackInfoElement.parentNode.removeChild(this.trackInfoElement);
123
- }
124
- if (this.navigationFeedback && this.navigationFeedback.parentNode) {
125
- this.navigationFeedback.parentNode.removeChild(this.navigationFeedback);
126
- }
127
- if (this.playlistPanel && this.playlistPanel.parentNode) {
128
- this.playlistPanel.parentNode.removeChild(this.playlistPanel);
129
- }
130
-
131
- // Remove event listeners before destroying
132
- if (this.player) {
133
- this.player.off('ended', this.handleTrackEnd);
134
- this.player.off('error', this.handleTrackError);
135
- this.player.destroy();
136
- }
137
-
138
- // Clear the host element
139
- this.hostElement.innerHTML = '';
140
-
141
- // Create new media element with appropriate type
142
- const mediaElement = document.createElement(elementType);
143
- mediaElement.setAttribute('preload', 'metadata');
144
-
145
- // For video elements with local media, set poster
146
- if (elementType === 'video' && track.poster &&
147
- (mediaType === 'video' || mediaType === 'hls')) {
148
- mediaElement.setAttribute('poster', track.poster);
149
- }
150
-
151
- // For external renderers (YouTube, Vimeo, SoundCloud, HLS), don't add source
152
- // The renderer will handle the source directly
153
- const isExternalRenderer = ['youtube', 'vimeo', 'soundcloud', 'hls'].includes(mediaType);
154
-
155
- if (!isExternalRenderer) {
156
- // Add source for HTML5 media
157
- const source = document.createElement('source');
158
- source.src = track.src;
159
- if (track.type) {
160
- source.type = track.type;
161
- }
162
- mediaElement.appendChild(source);
163
-
164
- // Add tracks (captions, chapters, etc.)
165
- if (track.tracks && track.tracks.length > 0) {
166
- track.tracks.forEach(trackConfig => {
167
- const trackEl = document.createElement('track');
168
- trackEl.src = trackConfig.src;
169
- trackEl.kind = trackConfig.kind || 'captions';
170
- trackEl.srclang = trackConfig.srclang || 'en';
171
- trackEl.label = trackConfig.label || trackConfig.srclang;
172
- if (trackConfig.default) {
173
- trackEl.default = true;
174
- }
175
- mediaElement.appendChild(trackEl);
176
- });
177
- }
178
- }
179
-
180
- this.hostElement.appendChild(mediaElement);
181
-
182
- // Create new player with the media element
183
- // Pass the source for external renderers via options
184
- const playerOptions = {
185
- mediaType: elementType,
186
- poster: track.poster,
187
- audioDescriptionSrc: track.audioDescriptionSrc || null,
188
- audioDescriptionDuration: track.audioDescriptionDuration || null,
189
- signLanguageSrc: track.signLanguageSrc || null
190
- };
191
-
192
- this.player = new this.PlayerClass(mediaElement, playerOptions);
193
-
194
- // Re-register playlist manager
195
- this.player.playlistManager = this;
196
-
197
- // Wait for player to be ready
198
- await new Promise(resolve => {
199
- this.player.on('ready', resolve);
200
- });
201
-
202
- // Re-attach event listeners
203
- this.player.on('ended', this.handleTrackEnd);
204
- this.player.on('error', this.handleTrackError);
205
-
206
- // Re-attach all playlist UI elements to the new player's container
207
- if (this.player.container) {
208
- // Track artwork goes before video wrapper
209
- if (this.trackArtworkElement) {
210
- const videoWrapper = this.player.container.querySelector('.vidply-video-wrapper');
211
- if (videoWrapper) {
212
- this.player.container.insertBefore(this.trackArtworkElement, videoWrapper);
213
- } else {
214
- this.player.container.appendChild(this.trackArtworkElement);
215
- }
216
- }
217
- // Track info
218
- if (this.trackInfoElement) {
219
- this.player.container.appendChild(this.trackInfoElement);
220
- }
221
- // Navigation feedback (screen reader only)
222
- if (this.navigationFeedback) {
223
- this.player.container.appendChild(this.navigationFeedback);
224
- }
225
- // Playlist panel
226
- if (this.playlistPanel) {
227
- this.player.container.appendChild(this.playlistPanel);
228
- }
229
- }
230
-
231
- // Update container reference
232
- this.container = this.player.container;
233
-
234
- // Update controls (adds playlist prev/next buttons)
235
- this.updatePlayerControls();
236
-
237
- // Restore tracks data (we kept it during recreation)
238
- this.tracks = savedTracks;
239
- this.currentIndex = savedIndex;
240
-
241
- // Update playlist UI to reflect current state
242
- this.updatePlaylistUI();
243
-
244
- // Restore playlist panel visibility
245
- this.isPanelVisible = wasVisible;
246
- if (this.playlistPanel) {
247
- this.playlistPanel.style.display = wasVisible ? '' : 'none';
248
- }
249
-
250
- // For external renderers, load the track via player.load()
251
- // For HTML5, the source is already set on the element
252
- if (isExternalRenderer) {
253
- this.player.load({
254
- src: track.src,
255
- type: track.type,
256
- poster: track.poster,
257
- tracks: track.tracks || [],
258
- audioDescriptionSrc: track.audioDescriptionSrc || null,
259
- signLanguageSrc: track.signLanguageSrc || null
260
- });
261
- } else {
262
- // For HTML5 media, also load to set up accessibility features
263
- this.player.load({
264
- src: track.src,
265
- type: track.type,
266
- poster: track.poster,
267
- tracks: track.tracks || [],
268
- audioDescriptionSrc: track.audioDescriptionSrc || null,
269
- signLanguageSrc: track.signLanguageSrc || null
270
- });
271
- }
272
-
273
- // Auto-play if requested
274
- if (autoPlay) {
275
- setTimeout(() => {
276
- this.player.play();
277
- }, 100);
278
- }
279
-
280
- return true;
281
- }
282
-
283
- init() {
284
- // Listen for track end
285
- this.player.on('ended', this.handleTrackEnd);
286
- this.player.on('error', this.handleTrackError);
287
-
288
- // Listen for playback state changes to show/hide playlist in fullscreen
289
- this.player.on('play', this.handlePlaybackStateChange.bind(this));
290
- this.player.on('pause', this.handlePlaybackStateChange.bind(this));
291
- this.player.on('ended', this.handlePlaybackStateChange.bind(this));
292
- // Use fullscreenchange event which is what the player actually emits
293
- this.player.on('fullscreenchange', this.handleFullscreenChange.bind(this));
294
-
295
- // Listen for audio description state changes to update duration displays
296
- this.player.on('audiodescriptionenabled', this.handleAudioDescriptionChange.bind(this));
297
- this.player.on('audiodescriptiondisabled', this.handleAudioDescriptionChange.bind(this));
298
-
299
- // Create UI if needed
300
- if (this.options.showPanel) {
301
- this.createUI();
302
- }
303
-
304
- // Check for data-playlist attribute on player container (only if tracks weren't provided in options)
305
- if (this.tracks.length === 0 && this.initialTracks.length === 0) {
306
- this.loadPlaylistFromAttribute();
307
- }
308
- }
309
-
310
- /**
311
- * Load playlist from data-playlist attribute if present
312
- */
313
- loadPlaylistFromAttribute() {
314
- // Check the original wrapper element for data-playlist
315
- // Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
316
- // So we need to go up 3 levels
317
- if (!this.player.element || !this.player.element.parentElement) {
318
- console.log('VidPly Playlist: No player element found');
319
- return;
320
- }
321
-
322
- const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
323
- const playerContainer = videoWrapper.parentElement; // .vidply-player
324
- const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
325
-
326
- if (!originalElement) {
327
- console.log('VidPly Playlist: No original element found');
328
- return;
329
- }
330
-
331
- // Load playlist options from data attributes
332
- this.loadOptionsFromAttributes(originalElement);
333
-
334
- const playlistData = originalElement.getAttribute('data-playlist');
335
- if (!playlistData) {
336
- console.log('VidPly Playlist: No data-playlist attribute found');
337
- return;
338
- }
339
-
340
- console.log('VidPly Playlist: Found data-playlist attribute, parsing...');
341
- try {
342
- const tracks = JSON.parse(playlistData);
343
- if (Array.isArray(tracks) && tracks.length > 0) {
344
- console.log(`VidPly Playlist: Loaded ${tracks.length} tracks from data-playlist`);
345
- this.loadPlaylist(tracks);
346
- } else {
347
- console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
348
- }
349
- } catch (error) {
350
- console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
351
- }
352
- }
353
-
354
- /**
355
- * Load playlist options from data attributes
356
- * @param {HTMLElement} element - Element to read attributes from
357
- */
358
- loadOptionsFromAttributes(element) {
359
- // data-playlist-auto-advance
360
- const autoAdvance = element.getAttribute('data-playlist-auto-advance');
361
- if (autoAdvance !== null) {
362
- this.options.autoAdvance = autoAdvance === 'true';
363
- }
364
-
365
- // data-playlist-auto-play-first
366
- const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
367
- if (autoPlayFirst !== null) {
368
- this.options.autoPlayFirst = autoPlayFirst === 'true';
369
- }
370
-
371
- // data-playlist-loop
372
- const loop = element.getAttribute('data-playlist-loop');
373
- if (loop !== null) {
374
- this.options.loop = loop === 'true';
375
- }
376
-
377
- // data-playlist-show-panel
378
- const showPanel = element.getAttribute('data-playlist-show-panel');
379
- if (showPanel !== null) {
380
- this.options.showPanel = showPanel === 'true';
381
- }
382
-
383
- console.log('VidPly Playlist: Options from attributes:', this.options);
384
- }
385
-
386
- /**
387
- * Update player controls to add playlist navigation buttons
388
- */
389
- updatePlayerControls() {
390
- if (!this.player.controlBar) return;
391
-
392
- const controlBar = this.player.controlBar;
393
-
394
- // Clear existing controls content (except the element itself)
395
- controlBar.element.innerHTML = '';
396
-
397
- // Recreate controls with playlist buttons now available
398
- controlBar.createControls();
399
-
400
- // Reattach events for the new controls
401
- controlBar.attachEvents();
402
- controlBar.setupAutoHide();
403
- }
404
-
405
- /**
406
- * Load a playlist
407
- * @param {Array} tracks - Array of track objects
408
- */
409
- loadPlaylist(tracks) {
410
- this.tracks = tracks;
411
- this.currentIndex = -1;
412
-
413
- // Add playlist class to container
414
- if (this.container) {
415
- this.container.classList.add('vidply-has-playlist');
416
- }
417
-
418
- // Update UI
419
- if (this.playlistPanel) {
420
- this.renderPlaylist();
421
- }
422
-
423
- // Auto-play first track (if enabled)
424
- if (tracks.length > 0) {
425
- if (this.options.autoPlayFirst) {
426
- this.play(0);
427
- } else {
428
- // Load first track without playing
429
- this.loadTrack(0);
430
- }
431
- }
432
-
433
- // Update visibility based on current state
434
- this.updatePlaylistVisibilityInFullscreen();
435
- }
436
-
437
- /**
438
- * Load a track without playing
439
- * @param {number} index - Track index
440
- */
441
- async loadTrack(index) {
442
- if (index < 0 || index >= this.tracks.length) {
443
- console.warn('VidPly Playlist: Invalid track index', index);
444
- return;
445
- }
446
-
447
- const track = this.tracks[index];
448
-
449
- // Set guard flag to prevent cascade of next() calls during track change
450
- this.isChangingTrack = true;
451
-
452
- // Update current index
453
- this.currentIndex = index;
454
-
455
- // Check if we should recreate the player for this track type
456
- if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
457
- const currentMediaType = this.player ?
458
- (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
459
- const newMediaType = this.getTrackMediaType(track);
460
- const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
461
-
462
- // Recreate if element type is different
463
- if (currentMediaType !== newElementType) {
464
- await this.recreatePlayerForTrack(track, false);
465
- // Update UI after recreation
466
- this.updateTrackInfo(track);
467
- this.updatePlaylistUI();
468
-
469
- // Emit event
470
- this.player.emit('playlisttrackchange', {
471
- index: index,
472
- item: track,
473
- total: this.tracks.length
474
- });
475
-
476
- // Clear guard flag
477
- setTimeout(() => {
478
- this.isChangingTrack = false;
479
- }, 150);
480
- return;
481
- }
482
- }
483
-
484
- // Load track into player (normal path)
485
- this.player.load({
486
- src: track.src,
487
- type: track.type,
488
- poster: track.poster,
489
- tracks: track.tracks || [],
490
- audioDescriptionSrc: track.audioDescriptionSrc || null,
491
- signLanguageSrc: track.signLanguageSrc || null
492
- });
493
-
494
- // Update UI
495
- this.updateTrackInfo(track);
496
- this.updatePlaylistUI();
497
-
498
- // Emit event
499
- this.player.emit('playlisttrackchange', {
500
- index: index,
501
- item: track,
502
- total: this.tracks.length
503
- });
504
-
505
- // Clear guard flag after a short delay to ensure track is loaded
506
- setTimeout(() => {
507
- this.isChangingTrack = false;
508
- }, 150);
509
- }
510
-
511
- /**
512
- * Play a specific track
513
- * @param {number} index - Track index
514
- * @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
515
- */
516
- async play(index, userInitiated = false) {
517
- if (index < 0 || index >= this.tracks.length) {
518
- console.warn('VidPly Playlist: Invalid track index', index);
519
- return;
520
- }
521
-
522
- const track = this.tracks[index];
523
-
524
- // Set guard flag to prevent cascade of next() calls during track change
525
- this.isChangingTrack = true;
526
-
527
- // Update current index
528
- this.currentIndex = index;
529
-
530
- // Check if we should recreate the player for this track type
531
- if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
532
- const currentMediaType = this.player ?
533
- (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
534
- const newMediaType = this.getTrackMediaType(track);
535
- const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
536
-
537
- // Recreate if element type is different
538
- if (currentMediaType !== newElementType) {
539
- await this.recreatePlayerForTrack(track, true); // true = autoPlay
540
- // Update UI after recreation
541
- this.updateTrackInfo(track);
542
- this.updatePlaylistUI();
543
-
544
- // Emit event
545
- this.player.emit('playlisttrackchange', {
546
- index: index,
547
- item: track,
548
- total: this.tracks.length
549
- });
550
-
551
- // Clear guard flag
552
- setTimeout(() => {
553
- this.isChangingTrack = false;
554
- }, 150);
555
- return;
556
- }
557
- }
558
-
559
- // Load track into player (normal path)
560
- this.player.load({
561
- src: track.src,
562
- type: track.type,
563
- poster: track.poster,
564
- tracks: track.tracks || [],
565
- audioDescriptionSrc: track.audioDescriptionSrc || null,
566
- signLanguageSrc: track.signLanguageSrc || null
567
- });
568
-
569
- // Update UI
570
- this.updateTrackInfo(track);
571
- this.updatePlaylistUI();
572
-
573
- // Emit event
574
- this.player.emit('playlisttrackchange', {
575
- index: index,
576
- item: track,
577
- total: this.tracks.length
578
- });
579
-
580
- // Auto-play and clear guard flag after playback starts
581
- setTimeout(() => {
582
- this.player.play();
583
- // Clear guard flag after a short delay to ensure track has started
584
- setTimeout(() => {
585
- this.isChangingTrack = false;
586
- }, 50);
587
- }, 100);
588
- }
589
-
590
- /**
591
- * Play next track
592
- */
593
- next() {
594
- let nextIndex = this.currentIndex + 1;
595
-
596
- if (nextIndex >= this.tracks.length) {
597
- if (this.options.loop) {
598
- nextIndex = 0;
599
- } else {
600
- return;
601
- }
602
- }
603
-
604
- this.play(nextIndex);
605
- }
606
-
607
- /**
608
- * Play previous track
609
- */
610
- previous() {
611
- let prevIndex = this.currentIndex - 1;
612
-
613
- if (prevIndex < 0) {
614
- if (this.options.loop) {
615
- prevIndex = this.tracks.length - 1;
616
- } else {
617
- return;
618
- }
619
- }
620
-
621
- this.play(prevIndex);
622
- }
623
-
624
- /**
625
- * Handle track end
626
- */
627
- handleTrackEnd() {
628
- // Don't auto-advance if we're already in the process of changing tracks
629
- // This prevents a cascade of next() calls when loading a new track triggers an 'ended' event
630
- if (this.isChangingTrack) {
631
- return;
632
- }
633
-
634
- if (this.options.autoAdvance) {
635
- this.next();
636
- }
637
- }
638
-
639
- /**
640
- * Check if a source URL requires an external renderer
641
- * @param {string} src - Source URL
642
- * @returns {boolean}
643
- */
644
- isExternalRendererUrl(src) {
645
- if (!src) return false;
646
- return src.includes('youtube.com') ||
647
- src.includes('youtu.be') ||
648
- src.includes('vimeo.com') ||
649
- src.includes('soundcloud.com') ||
650
- src.includes('api.soundcloud.com') ||
651
- src.includes('.m3u8');
652
- }
653
-
654
- /**
655
- * Handle track error
656
- */
657
- handleTrackError(e) {
658
- // Don't auto-advance for external renderer tracks
659
- // External renderers (YouTube, Vimeo, SoundCloud, HLS) may trigger HTML5 errors
660
- // that should be ignored since the external renderer handles playback
661
- const currentTrack = this.getCurrentTrack();
662
- if (currentTrack && currentTrack.src && this.isExternalRendererUrl(currentTrack.src)) {
663
- // Silently ignore errors for external renderer tracks
664
- return;
665
- }
666
-
667
- // Don't auto-advance if we're in the process of changing tracks
668
- // This prevents a cascade of next() calls when switching between renderer types
669
- if (this.isChangingTrack) {
670
- return;
671
- }
672
-
673
- console.error('VidPly Playlist: Track error', e);
674
-
675
- // Try next track
676
- if (this.options.autoAdvance) {
677
- setTimeout(() => {
678
- this.next();
679
- }, 1000);
680
- }
681
- }
682
-
683
- /**
684
- * Handle playback state changes (for fullscreen playlist visibility)
685
- */
686
- handlePlaybackStateChange() {
687
- this.updatePlaylistVisibilityInFullscreen();
688
- }
689
-
690
- /**
691
- * Handle fullscreen state changes
692
- */
693
- handleFullscreenChange() {
694
- // Use a small delay to ensure fullscreen state is fully applied
695
- setTimeout(() => {
696
- this.updatePlaylistVisibilityInFullscreen();
697
- }, 50);
698
- }
699
-
700
- /**
701
- * Handle audio description state changes
702
- * Updates duration displays to show audio-described version duration when AD is enabled
703
- */
704
- handleAudioDescriptionChange() {
705
- const currentTrack = this.getCurrentTrack();
706
- if (!currentTrack) return;
707
-
708
- // Update the track info display with the appropriate duration
709
- this.updateTrackInfo(currentTrack);
710
-
711
- // Update the playlist UI to reflect duration changes (aria-labels)
712
- this.updatePlaylistUI();
713
-
714
- // Update visual duration elements in playlist panel
715
- this.updatePlaylistDurations();
716
- }
717
-
718
- /**
719
- * Update the visual duration displays in the playlist panel
720
- * Called when audio description state changes
721
- */
722
- updatePlaylistDurations() {
723
- if (!this.playlistPanel) return;
724
-
725
- const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
726
-
727
- items.forEach((item, index) => {
728
- const track = this.tracks[index];
729
- if (!track) return;
730
-
731
- const effectiveDuration = this.getEffectiveDuration(track);
732
- const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
733
-
734
- // Update duration badge on thumbnail (if exists)
735
- const durationBadge = item.querySelector('.vidply-playlist-duration-badge');
736
- if (durationBadge) {
737
- durationBadge.textContent = trackDuration;
738
- }
739
-
740
- // Update inline duration (if exists)
741
- const inlineDuration = item.querySelector('.vidply-playlist-item-duration');
742
- if (inlineDuration) {
743
- inlineDuration.textContent = trackDuration;
744
- }
745
- });
746
- }
747
-
748
- /**
749
- * Get the effective duration for a track based on audio description state
750
- * @param {Object} track - Track object
751
- * @returns {number|null} - Duration in seconds or null if not available
752
- */
753
- getEffectiveDuration(track) {
754
- if (!track) return null;
755
-
756
- const isAudioDescriptionEnabled = this.player.state.audioDescriptionEnabled;
757
-
758
- // If audio description is enabled and track has audioDescriptionDuration, use it
759
- if (isAudioDescriptionEnabled && track.audioDescriptionDuration) {
760
- return track.audioDescriptionDuration;
761
- }
762
-
763
- // Otherwise use regular duration
764
- return track.duration || null;
765
- }
766
-
767
- /**
768
- * Update playlist visibility based on fullscreen and playback state
769
- * In fullscreen: show when paused/not started, hide when playing
770
- * Outside fullscreen: respect original panel visibility setting
771
- */
772
- updatePlaylistVisibilityInFullscreen() {
773
- if (!this.playlistPanel || !this.tracks.length) return;
774
-
775
- const isFullscreen = this.player.state.fullscreen;
776
- const isPlaying = this.player.state.playing;
777
-
778
- if (isFullscreen) {
779
- // In fullscreen: show only when not playing (paused or not started)
780
- // Check playing state explicitly since paused might not be set initially
781
- if (!isPlaying) {
782
- this.playlistPanel.classList.add('vidply-playlist-fullscreen-visible');
783
- this.playlistPanel.style.display = 'block';
784
- } else {
785
- this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
786
- // Add a smooth fade out with delay to match CSS transition
787
- setTimeout(() => {
788
- // Double-check state hasn't changed before hiding
789
- if (this.player.state.playing && this.player.state.fullscreen) {
790
- this.playlistPanel.style.display = 'none';
791
- }
792
- }, 300); // Match CSS transition duration
793
- }
794
- } else {
795
- // Outside fullscreen: restore original behavior
796
- this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
797
- if (this.isPanelVisible && this.tracks.length > 0) {
798
- this.playlistPanel.style.display = 'block';
799
- } else {
800
- this.playlistPanel.style.display = 'none';
801
- }
802
- }
803
- }
804
-
805
- /**
806
- * Create playlist UI
807
- */
808
- createUI() {
809
- // Find player container
810
- this.container = this.player.container;
811
-
812
- if (!this.container) {
813
- console.warn('VidPly Playlist: No container found');
814
- return;
815
- }
816
-
817
- // Create track artwork element (shows album art/poster for audio playlists)
818
- // Only create for audio players
819
- if (this.player.element.tagName === 'AUDIO') {
820
- this.trackArtworkElement = DOMUtils.createElement('div', {
821
- className: 'vidply-track-artwork',
822
- attributes: {
823
- 'aria-hidden': 'true'
824
- }
825
- });
826
- this.trackArtworkElement.style.display = 'none';
827
-
828
- // Insert before video wrapper
829
- const videoWrapper = this.container.querySelector('.vidply-video-wrapper');
830
- if (videoWrapper) {
831
- this.container.insertBefore(this.trackArtworkElement, videoWrapper);
832
- } else {
833
- this.container.appendChild(this.trackArtworkElement);
834
- }
835
- }
836
-
837
- // Create track info element (shows current track)
838
- this.trackInfoElement = DOMUtils.createElement('div', {
839
- className: 'vidply-track-info',
840
- attributes: {
841
- role: 'status'
842
- }
843
- });
844
- this.trackInfoElement.style.display = 'none';
845
-
846
- this.container.appendChild(this.trackInfoElement);
847
-
848
- // Create navigation feedback live region
849
- this.navigationFeedback = DOMUtils.createElement('div', {
850
- className: 'vidply-sr-only',
851
- attributes: {
852
- role: 'status',
853
- 'aria-live': 'polite',
854
- 'aria-atomic': 'true'
855
- }
856
- });
857
- this.container.appendChild(this.navigationFeedback);
858
-
859
- // Create playlist panel with proper landmark
860
- this.playlistPanel = DOMUtils.createElement('div', {
861
- className: 'vidply-playlist-panel',
862
- attributes: {
863
- id: `${this.uniqueId}-panel`,
864
- role: 'region',
865
- 'aria-label': i18n.t('playlist.title'),
866
- 'aria-labelledby': `${this.uniqueId}-heading`
867
- }
868
- });
869
- this.playlistPanel.style.display = this.isPanelVisible ? 'none' : 'none'; // Will be shown when playlist is loaded
870
-
871
- this.container.appendChild(this.playlistPanel);
872
- }
873
-
874
- /**
875
- * Update track info display
876
- */
877
- updateTrackInfo(track) {
878
- if (!this.trackInfoElement) return;
879
-
880
- const trackNumber = this.currentIndex + 1;
881
- const totalTracks = this.tracks.length;
882
- const trackTitle = track.title || i18n.t('playlist.untitled');
883
- const trackArtist = track.artist || '';
884
-
885
- // Use effective duration (audio description duration when AD is enabled)
886
- const effectiveDuration = this.getEffectiveDuration(track);
887
- const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
888
- const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
889
-
890
- // Screen reader announcement - include duration if available
891
- const artistPart = trackArtist ? i18n.t('playlist.by') + trackArtist : '';
892
- const durationPart = trackDurationReadable ? `. ${trackDurationReadable}` : '';
893
- const announcement = i18n.t('playlist.nowPlaying', {
894
- current: trackNumber,
895
- total: totalTracks,
896
- title: trackTitle,
897
- artist: artistPart
898
- }) + durationPart;
899
-
900
- const trackOfText = i18n.t('playlist.trackOf', {
901
- current: trackNumber,
902
- total: totalTracks
903
- });
904
-
905
- // Build duration HTML if available
906
- const durationHtml = trackDuration
907
- ? `<span class="vidply-track-duration" aria-hidden="true">${DOMUtils.escapeHTML(trackDuration)}</span>`
908
- : '';
909
-
910
- // Get description if available
911
- const trackDescription = track.description || '';
912
-
913
- this.trackInfoElement.innerHTML = `
914
- <span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
915
- <div class="vidply-track-header" aria-hidden="true">
916
- <span class="vidply-track-number">${DOMUtils.escapeHTML(trackOfText)}</span>
917
- ${durationHtml}
918
- </div>
919
- <div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
920
- ${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
921
- ${trackDescription ? `<div class="vidply-track-description" aria-hidden="true">${DOMUtils.escapeHTML(trackDescription)}</div>` : ''}
922
- `;
923
-
924
- this.trackInfoElement.style.display = 'block';
925
-
926
- // Update track artwork if available (for audio playlists)
927
- this.updateTrackArtwork(track);
928
- }
929
-
930
- /**
931
- * Update track artwork display (for audio playlists)
932
- */
933
- updateTrackArtwork(track) {
934
- if (!this.trackArtworkElement) return;
935
-
936
- // If track has a poster/artwork, show it
937
- if (track.poster) {
938
- this.trackArtworkElement.style.backgroundImage = `url(${track.poster})`;
939
- this.trackArtworkElement.style.display = 'block';
940
- } else {
941
- // No artwork available, hide the element
942
- this.trackArtworkElement.style.display = 'none';
943
- }
944
- }
945
-
946
- /**
947
- * Render playlist
948
- */
949
- renderPlaylist() {
950
- if (!this.playlistPanel) return;
951
-
952
- // Clear existing
953
- this.playlistPanel.innerHTML = '';
954
-
955
- // Create header
956
- const header = DOMUtils.createElement('h2', {
957
- className: 'vidply-playlist-header',
958
- attributes: {
959
- id: `${this.uniqueId}-heading`
960
- }
961
- });
962
- header.textContent = `${i18n.t('playlist.title')} (${this.tracks.length})`;
963
- this.playlistPanel.appendChild(header);
964
-
965
- // Add keyboard instructions (visually hidden)
966
- const instructions = DOMUtils.createElement('div', {
967
- className: 'vidply-sr-only',
968
- attributes: {
969
- id: `${this.uniqueId}-keyboard-instructions`
970
- }
971
- });
972
- instructions.textContent = i18n.t('playlist.keyboardInstructions');
973
- this.playlistPanel.appendChild(instructions);
974
-
975
- // Create list (proper ul element)
976
- const list = DOMUtils.createElement('ul', {
977
- className: 'vidply-playlist-list',
978
- attributes: {
979
- role: 'listbox',
980
- 'aria-labelledby': `${this.uniqueId}-heading`,
981
- 'aria-describedby': `${this.uniqueId}-keyboard-instructions`
982
- }
983
- });
984
-
985
- this.tracks.forEach((track, index) => {
986
- const item = this.createPlaylistItem(track, index);
987
- list.appendChild(item);
988
- });
989
-
990
- this.playlistPanel.appendChild(list);
991
-
992
- // Show panel if it should be visible
993
- if (this.isPanelVisible) {
994
- this.playlistPanel.style.display = 'block';
995
- }
996
- }
997
-
998
- /**
999
- * Create playlist item element
1000
- */
1001
- createPlaylistItem(track, index) {
1002
- const trackPosition = i18n.t('playlist.trackOf', {
1003
- current: index + 1,
1004
- total: this.tracks.length
1005
- });
1006
- const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1007
- const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1008
-
1009
- // Use effective duration (audio description duration when AD is enabled)
1010
- const effectiveDuration = this.getEffectiveDuration(track);
1011
- const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
1012
- const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
1013
- const isActive = index === this.currentIndex;
1014
-
1015
- // Build accessible label for screen readers
1016
- // With role="option" and aria-checked, screen reader will announce selection state
1017
- // Position is already announced via aria-posinset/aria-setsize
1018
- // Format: "Title by Artist. 3 minutes, 45 seconds."
1019
- let ariaLabel = `${trackTitle}${trackArtist}`;
1020
- if (trackDurationReadable) {
1021
- ariaLabel += `. ${trackDurationReadable}`;
1022
- }
1023
-
1024
- // Create list item container (semantic HTML)
1025
- const item = DOMUtils.createElement('li', {
1026
- className: isActive ? 'vidply-playlist-item vidply-playlist-item-active' : 'vidply-playlist-item',
1027
- attributes: {
1028
- 'data-playlist-index': index
1029
- }
1030
- });
1031
-
1032
- // Create button wrapper for interactive content
1033
- const button = DOMUtils.createElement('button', {
1034
- className: 'vidply-playlist-item-button',
1035
- attributes: {
1036
- type: 'button',
1037
- role: 'option',
1038
- tabIndex: index === 0 ? 0 : -1, // Only first item is in tab order initially
1039
- 'aria-label': ariaLabel,
1040
- 'aria-posinset': index + 1,
1041
- 'aria-setsize': this.tracks.length,
1042
- 'aria-checked': isActive ? 'true' : 'false'
1043
- }
1044
- });
1045
-
1046
- // Add aria-current if active
1047
- if (isActive) {
1048
- button.setAttribute('aria-current', 'true');
1049
- button.setAttribute('tabIndex', '0'); // Active item should always be tabbable
1050
- }
1051
-
1052
- // Thumbnail container with optional duration badge
1053
- const thumbnailContainer = DOMUtils.createElement('span', {
1054
- className: 'vidply-playlist-thumbnail-container',
1055
- attributes: {
1056
- 'aria-hidden': 'true'
1057
- }
1058
- });
1059
-
1060
- // Thumbnail or icon
1061
- const thumbnail = DOMUtils.createElement('span', {
1062
- className: 'vidply-playlist-thumbnail'
1063
- });
1064
-
1065
- if (track.poster) {
1066
- thumbnail.style.backgroundImage = `url(${track.poster})`;
1067
- } else {
1068
- // Show music/speaker icon for audio tracks
1069
- const icon = createIconElement('music');
1070
- icon.classList.add('vidply-playlist-thumbnail-icon');
1071
- thumbnail.appendChild(icon);
1072
- }
1073
-
1074
- thumbnailContainer.appendChild(thumbnail);
1075
-
1076
- // Duration badge on thumbnail (like YouTube) - only show if there's a poster
1077
- if (trackDuration && track.poster) {
1078
- const durationBadge = DOMUtils.createElement('span', {
1079
- className: 'vidply-playlist-duration-badge'
1080
- });
1081
- durationBadge.textContent = trackDuration;
1082
- thumbnailContainer.appendChild(durationBadge);
1083
- }
1084
-
1085
- button.appendChild(thumbnailContainer);
1086
-
1087
- // Info section (title, artist, description)
1088
- const info = DOMUtils.createElement('span', {
1089
- className: 'vidply-playlist-item-info',
1090
- attributes: {
1091
- 'aria-hidden': 'true'
1092
- }
1093
- });
1094
-
1095
- // Title row with optional inline duration (for when no thumbnail)
1096
- const titleRow = DOMUtils.createElement('span', {
1097
- className: 'vidply-playlist-item-title-row'
1098
- });
1099
-
1100
- const title = DOMUtils.createElement('span', {
1101
- className: 'vidply-playlist-item-title'
1102
- });
1103
- title.textContent = trackTitle;
1104
- titleRow.appendChild(title);
1105
-
1106
- // Inline duration (shown when no poster/thumbnail)
1107
- if (trackDuration && !track.poster) {
1108
- const inlineDuration = DOMUtils.createElement('span', {
1109
- className: 'vidply-playlist-item-duration'
1110
- });
1111
- inlineDuration.textContent = trackDuration;
1112
- titleRow.appendChild(inlineDuration);
1113
- }
1114
-
1115
- info.appendChild(titleRow);
1116
-
1117
- // Artist
1118
- if (track.artist) {
1119
- const artist = DOMUtils.createElement('span', {
1120
- className: 'vidply-playlist-item-artist'
1121
- });
1122
- artist.textContent = track.artist;
1123
- info.appendChild(artist);
1124
- }
1125
-
1126
- // Description (truncated)
1127
- if (track.description) {
1128
- const description = DOMUtils.createElement('span', {
1129
- className: 'vidply-playlist-item-description'
1130
- });
1131
- description.textContent = track.description;
1132
- info.appendChild(description);
1133
- }
1134
-
1135
- button.appendChild(info);
1136
-
1137
- // Play icon
1138
- const playIcon = createIconElement('play');
1139
- playIcon.classList.add('vidply-playlist-item-icon');
1140
- playIcon.setAttribute('aria-hidden', 'true');
1141
- button.appendChild(playIcon);
1142
-
1143
- // Click handler
1144
- button.addEventListener('click', () => {
1145
- this.play(index, true); // User-initiated
1146
- });
1147
-
1148
- // Keyboard handler
1149
- button.addEventListener('keydown', (e) => {
1150
- this.handlePlaylistItemKeydown(e, index);
1151
- });
1152
-
1153
- // Append button to list item
1154
- item.appendChild(button);
1155
-
1156
- return item;
1157
- }
1158
-
1159
- /**
1160
- * Handle keyboard navigation in playlist items
1161
- */
1162
- handlePlaylistItemKeydown(e, index) {
1163
- const buttons = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item-button'));
1164
- let newIndex = -1;
1165
- let announcement = '';
1166
-
1167
- switch(e.key) {
1168
- case 'Enter':
1169
- case ' ':
1170
- e.preventDefault();
1171
- e.stopPropagation();
1172
- this.play(index, true); // User-initiated
1173
- return; // No need to move focus
1174
-
1175
- case 'ArrowDown':
1176
- e.preventDefault();
1177
- e.stopPropagation();
1178
- // Move to next item
1179
- if (index < buttons.length - 1) {
1180
- newIndex = index + 1;
1181
- } else {
1182
- // At the end, announce boundary
1183
- announcement = i18n.t('playlist.endOfPlaylist', { current: buttons.length, total: buttons.length });
1184
- }
1185
- break;
1186
-
1187
- case 'ArrowUp':
1188
- e.preventDefault();
1189
- e.stopPropagation();
1190
- // Move to previous item
1191
- if (index > 0) {
1192
- newIndex = index - 1;
1193
- } else {
1194
- // At the beginning, announce boundary
1195
- announcement = i18n.t('playlist.beginningOfPlaylist', { total: buttons.length });
1196
- }
1197
- break;
1198
-
1199
- case 'PageDown':
1200
- e.preventDefault();
1201
- e.stopPropagation();
1202
- // Move 5 items down (or to end)
1203
- newIndex = Math.min(index + 5, buttons.length - 1);
1204
- if (newIndex === buttons.length - 1 && index !== newIndex) {
1205
- announcement = i18n.t('playlist.jumpedToLastTrack', { current: newIndex + 1, total: buttons.length });
1206
- }
1207
- break;
1208
-
1209
- case 'PageUp':
1210
- e.preventDefault();
1211
- e.stopPropagation();
1212
- // Move 5 items up (or to beginning)
1213
- newIndex = Math.max(index - 5, 0);
1214
- if (newIndex === 0 && index !== newIndex) {
1215
- announcement = i18n.t('playlist.jumpedToFirstTrack', { total: buttons.length });
1216
- }
1217
- break;
1218
-
1219
- case 'Home':
1220
- e.preventDefault();
1221
- e.stopPropagation();
1222
- // Move to first item
1223
- newIndex = 0;
1224
- if (index !== 0) {
1225
- announcement = i18n.t('playlist.firstTrack', { total: buttons.length });
1226
- }
1227
- break;
1228
-
1229
- case 'End':
1230
- e.preventDefault();
1231
- e.stopPropagation();
1232
- // Move to last item
1233
- newIndex = buttons.length - 1;
1234
- if (index !== buttons.length - 1) {
1235
- announcement = i18n.t('playlist.lastTrack', { current: buttons.length, total: buttons.length });
1236
- }
1237
- break;
1238
- }
1239
-
1240
- // Update tab indices for roving tabindex pattern
1241
- if (newIndex !== -1 && newIndex !== index) {
1242
- buttons[index].setAttribute('tabIndex', '-1');
1243
- buttons[newIndex].setAttribute('tabIndex', '0');
1244
- buttons[newIndex].focus({ preventScroll: false });
1245
-
1246
- // Scroll the focused item into view (same behavior as mouse interaction)
1247
- const item = buttons[newIndex].closest('.vidply-playlist-item');
1248
- if (item) {
1249
- item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1250
- }
1251
- }
1252
-
1253
- // Announce navigation feedback
1254
- if (announcement && this.navigationFeedback) {
1255
- this.navigationFeedback.textContent = announcement;
1256
- // Clear after a short delay to allow for repeated announcements
1257
- setTimeout(() => {
1258
- if (this.navigationFeedback) {
1259
- this.navigationFeedback.textContent = '';
1260
- }
1261
- }, 1000);
1262
- }
1263
- }
1264
-
1265
- /**
1266
- * Update playlist UI (highlight current track)
1267
- */
1268
- updatePlaylistUI() {
1269
- if (!this.playlistPanel) return;
1270
-
1271
- const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
1272
- const buttons = this.playlistPanel.querySelectorAll('.vidply-playlist-item-button');
1273
-
1274
- items.forEach((item, index) => {
1275
- const button = buttons[index];
1276
- if (!button) return;
1277
-
1278
- const track = this.tracks[index];
1279
- const trackPosition = i18n.t('playlist.trackOf', {
1280
- current: index + 1,
1281
- total: this.tracks.length
1282
- });
1283
- const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1284
- const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1285
-
1286
- // Use effective duration (audio description duration when AD is enabled)
1287
- const effectiveDuration = this.getEffectiveDuration(track);
1288
- const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
1289
-
1290
- if (index === this.currentIndex) {
1291
- // Update list item styling
1292
- item.classList.add('vidply-playlist-item-active');
1293
-
1294
- // Update button ARIA attributes
1295
- button.setAttribute('aria-current', 'true');
1296
- button.setAttribute('aria-checked', 'true');
1297
- button.setAttribute('tabIndex', '0'); // Active item should be tabbable
1298
-
1299
- // Simplified aria-label - status and actions are announced via ARIA roles
1300
- let ariaLabel = `${trackTitle}${trackArtist}`;
1301
- if (trackDurationReadable) {
1302
- ariaLabel += `. ${trackDurationReadable}`;
1303
- }
1304
- button.setAttribute('aria-label', ariaLabel);
1305
-
1306
- // Scroll into view within playlist panel (uses 'nearest' to minimize page scroll)
1307
- item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1308
- } else {
1309
- // Update list item styling
1310
- item.classList.remove('vidply-playlist-item-active');
1311
-
1312
- // Update button ARIA attributes
1313
- button.removeAttribute('aria-current');
1314
- button.setAttribute('aria-checked', 'false');
1315
- button.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
1316
-
1317
- // Simplified aria-label - status and actions are announced via ARIA roles
1318
- let ariaLabel = `${trackTitle}${trackArtist}`;
1319
- if (trackDurationReadable) {
1320
- ariaLabel += `. ${trackDurationReadable}`;
1321
- }
1322
- button.setAttribute('aria-label', ariaLabel);
1323
- }
1324
- });
1325
- }
1326
-
1327
- /**
1328
- * Get current track
1329
- */
1330
- getCurrentTrack() {
1331
- return this.tracks[this.currentIndex] || null;
1332
- }
1333
-
1334
- /**
1335
- * Get playlist info
1336
- */
1337
- getPlaylistInfo() {
1338
- return {
1339
- currentIndex: this.currentIndex,
1340
- totalTracks: this.tracks.length,
1341
- currentTrack: this.getCurrentTrack(),
1342
- hasNext: this.hasNext(),
1343
- hasPrevious: this.hasPrevious()
1344
- };
1345
- }
1346
-
1347
- /**
1348
- * Check if there is a next track
1349
- */
1350
- hasNext() {
1351
- if (this.options.loop) return true;
1352
- return this.currentIndex < this.tracks.length - 1;
1353
- }
1354
-
1355
- /**
1356
- * Check if there is a previous track
1357
- */
1358
- hasPrevious() {
1359
- if (this.options.loop) return true;
1360
- return this.currentIndex > 0;
1361
- }
1362
-
1363
- /**
1364
- * Add track to playlist
1365
- */
1366
- addTrack(track) {
1367
- this.tracks.push(track);
1368
-
1369
- if (this.playlistPanel) {
1370
- this.renderPlaylist();
1371
- }
1372
- }
1373
-
1374
- /**
1375
- * Remove track from playlist
1376
- */
1377
- removeTrack(index) {
1378
- if (index < 0 || index >= this.tracks.length) return;
1379
-
1380
- this.tracks.splice(index, 1);
1381
-
1382
- // Adjust current index if needed
1383
- if (index < this.currentIndex) {
1384
- this.currentIndex--;
1385
- } else if (index === this.currentIndex) {
1386
- // Current track was removed, play next or stop
1387
- if (this.currentIndex >= this.tracks.length) {
1388
- this.currentIndex = this.tracks.length - 1;
1389
- }
1390
-
1391
- if (this.currentIndex >= 0) {
1392
- this.play(this.currentIndex);
1393
- }
1394
- }
1395
-
1396
- if (this.playlistPanel) {
1397
- this.renderPlaylist();
1398
- }
1399
- }
1400
-
1401
- /**
1402
- * Clear playlist
1403
- */
1404
- clear() {
1405
- this.tracks = [];
1406
- this.currentIndex = -1;
1407
-
1408
- if (this.playlistPanel) {
1409
- this.playlistPanel.innerHTML = '';
1410
- this.playlistPanel.style.display = 'none';
1411
- }
1412
-
1413
- if (this.trackInfoElement) {
1414
- this.trackInfoElement.innerHTML = '';
1415
- this.trackInfoElement.style.display = 'none';
1416
- }
1417
-
1418
- if (this.trackArtworkElement) {
1419
- this.trackArtworkElement.style.backgroundImage = '';
1420
- this.trackArtworkElement.style.display = 'none';
1421
- }
1422
- }
1423
-
1424
- /**
1425
- * Toggle playlist panel visibility
1426
- * @param {boolean} show - Optional: force show (true) or hide (false)
1427
- * @returns {boolean} - New visibility state
1428
- */
1429
- togglePanel(show) {
1430
- if (!this.playlistPanel) return false;
1431
-
1432
- // Determine new state
1433
- const shouldShow = show !== undefined ? show : this.playlistPanel.style.display === 'none';
1434
-
1435
- if (shouldShow) {
1436
- this.playlistPanel.style.display = 'block';
1437
- this.isPanelVisible = true;
1438
-
1439
- // Focus first item if playlist has tracks
1440
- if (this.tracks.length > 0) {
1441
- setTimeout(() => {
1442
- const firstItem = this.playlistPanel.querySelector('.vidply-playlist-item[tabindex="0"]');
1443
- if (firstItem) {
1444
- firstItem.focus({ preventScroll: true });
1445
- }
1446
- }, 100);
1447
- }
1448
-
1449
- // Update toggle button state if it exists
1450
- if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1451
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'true');
1452
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'true');
1453
- }
1454
- } else {
1455
- this.playlistPanel.style.display = 'none';
1456
- this.isPanelVisible = false;
1457
-
1458
- // Update toggle button state if it exists
1459
- if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1460
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'false');
1461
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'false');
1462
-
1463
- // Return focus to toggle button
1464
- this.player.controlBar.controls.playlistToggle.focus({ preventScroll: true });
1465
- }
1466
- }
1467
-
1468
- return this.isPanelVisible;
1469
- }
1470
-
1471
- /**
1472
- * Show playlist panel
1473
- */
1474
- showPanel() {
1475
- return this.togglePanel(true);
1476
- }
1477
-
1478
- /**
1479
- * Hide playlist panel
1480
- */
1481
- hidePanel() {
1482
- return this.togglePanel(false);
1483
- }
1484
-
1485
- /**
1486
- * Destroy playlist manager
1487
- */
1488
- destroy() {
1489
- // Remove event listeners
1490
- this.player.off('ended', this.handleTrackEnd);
1491
- this.player.off('error', this.handleTrackError);
1492
-
1493
- // Remove UI
1494
- if (this.trackArtworkElement) {
1495
- this.trackArtworkElement.remove();
1496
- }
1497
-
1498
- if (this.trackInfoElement) {
1499
- this.trackInfoElement.remove();
1500
- }
1501
-
1502
- if (this.playlistPanel) {
1503
- this.playlistPanel.remove();
1504
- }
1505
-
1506
- // Clear data
1507
- this.clear();
1508
- }
1509
- }
1510
-
1511
- 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
+ recreatePlayers: options.recreatePlayers || false, // New: recreate player for each track type
32
+ ...options
33
+ };
34
+
35
+ // UI elements
36
+ this.container = null;
37
+ this.playlistPanel = null;
38
+ this.trackInfoElement = null;
39
+ this.navigationFeedback = null; // Live region for keyboard navigation feedback
40
+ this.isPanelVisible = this.options.showPanel !== false;
41
+
42
+ // Track change guard to prevent cascade of next() calls
43
+ this.isChangingTrack = false;
44
+
45
+ // Store the host element for player recreation
46
+ this.hostElement = options.hostElement || null;
47
+ this.PlayerClass = options.PlayerClass || null;
48
+
49
+ // Bind methods
50
+ this.handleTrackEnd = this.handleTrackEnd.bind(this);
51
+ this.handleTrackError = this.handleTrackError.bind(this);
52
+
53
+ // Register this playlist manager with the player
54
+ this.player.playlistManager = this;
55
+
56
+ // Initialize
57
+ this.init();
58
+
59
+ // Update controls to add playlist buttons
60
+ this.updatePlayerControls();
61
+
62
+ // Load tracks if provided in options (after UI is ready)
63
+ if (this.initialTracks.length > 0) {
64
+ this.loadPlaylist(this.initialTracks);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Determine the media type for a track
70
+ * @param {Object} track - Track object
71
+ * @returns {string} - 'audio', 'video', 'youtube', 'vimeo', 'soundcloud', 'hls'
72
+ */
73
+ getTrackMediaType(track) {
74
+ const src = track.src || '';
75
+
76
+ if (src.includes('youtube.com') || src.includes('youtu.be')) {
77
+ return 'youtube';
78
+ }
79
+ if (src.includes('vimeo.com')) {
80
+ return 'vimeo';
81
+ }
82
+ if (src.includes('soundcloud.com') || src.includes('api.soundcloud.com')) {
83
+ return 'soundcloud';
84
+ }
85
+ if (src.includes('.m3u8')) {
86
+ return 'hls';
87
+ }
88
+ if (track.type && track.type.startsWith('audio/')) {
89
+ return 'audio';
90
+ }
91
+ // Default to video for video types or unknown
92
+ return 'video';
93
+ }
94
+
95
+ /**
96
+ * Recreate the player with the appropriate element type for the track
97
+ * @param {Object} track - Track to load
98
+ * @param {boolean} autoPlay - Whether to auto-play after creation
99
+ */
100
+ async recreatePlayerForTrack(track, autoPlay = false) {
101
+ if (!this.hostElement || !this.PlayerClass) {
102
+ console.warn('VidPly Playlist: Cannot recreate player - missing hostElement or PlayerClass');
103
+ return false;
104
+ }
105
+
106
+ const mediaType = this.getTrackMediaType(track);
107
+ // SoundCloud uses an iframe widget, so it doesn't need an audio element
108
+ // Only local audio files need an actual <audio> element
109
+ const elementType = (mediaType === 'audio') ? 'audio' : 'video';
110
+
111
+ // Store playlist panel state
112
+ const wasVisible = this.isPanelVisible;
113
+ const savedTracks = [...this.tracks]; // Keep track data
114
+ const savedIndex = this.currentIndex;
115
+
116
+ // Detach all playlist UI elements from DOM (keep references)
117
+ // These will be reattached to the new player container
118
+ if (this.trackArtworkElement && this.trackArtworkElement.parentNode) {
119
+ this.trackArtworkElement.parentNode.removeChild(this.trackArtworkElement);
120
+ }
121
+ if (this.trackInfoElement && this.trackInfoElement.parentNode) {
122
+ this.trackInfoElement.parentNode.removeChild(this.trackInfoElement);
123
+ }
124
+ if (this.navigationFeedback && this.navigationFeedback.parentNode) {
125
+ this.navigationFeedback.parentNode.removeChild(this.navigationFeedback);
126
+ }
127
+ if (this.playlistPanel && this.playlistPanel.parentNode) {
128
+ this.playlistPanel.parentNode.removeChild(this.playlistPanel);
129
+ }
130
+
131
+ // Remove event listeners before destroying
132
+ if (this.player) {
133
+ this.player.off('ended', this.handleTrackEnd);
134
+ this.player.off('error', this.handleTrackError);
135
+ this.player.destroy();
136
+ }
137
+
138
+ // Clear the host element
139
+ this.hostElement.innerHTML = '';
140
+
141
+ // Create new media element with appropriate type
142
+ const mediaElement = document.createElement(elementType);
143
+ mediaElement.setAttribute('preload', 'metadata');
144
+
145
+ // For video elements with local media, set poster
146
+ if (elementType === 'video' && track.poster &&
147
+ (mediaType === 'video' || mediaType === 'hls')) {
148
+ mediaElement.setAttribute('poster', track.poster);
149
+ }
150
+
151
+ // For external renderers (YouTube, Vimeo, SoundCloud, HLS), don't add source
152
+ // The renderer will handle the source directly
153
+ const isExternalRenderer = ['youtube', 'vimeo', 'soundcloud', 'hls'].includes(mediaType);
154
+
155
+ if (!isExternalRenderer) {
156
+ // Add source for HTML5 media
157
+ const source = document.createElement('source');
158
+ source.src = track.src;
159
+ if (track.type) {
160
+ source.type = track.type;
161
+ }
162
+ mediaElement.appendChild(source);
163
+
164
+ // Add tracks (captions, chapters, etc.)
165
+ if (track.tracks && track.tracks.length > 0) {
166
+ track.tracks.forEach(trackConfig => {
167
+ const trackEl = document.createElement('track');
168
+ trackEl.src = trackConfig.src;
169
+ trackEl.kind = trackConfig.kind || 'captions';
170
+ trackEl.srclang = trackConfig.srclang || 'en';
171
+ trackEl.label = trackConfig.label || trackConfig.srclang;
172
+ if (trackConfig.default) {
173
+ trackEl.default = true;
174
+ }
175
+ mediaElement.appendChild(trackEl);
176
+ });
177
+ }
178
+ }
179
+
180
+ this.hostElement.appendChild(mediaElement);
181
+
182
+ // Create new player with the media element
183
+ // Pass the source for external renderers via options
184
+ const playerOptions = {
185
+ mediaType: elementType,
186
+ poster: track.poster,
187
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
188
+ audioDescriptionDuration: track.audioDescriptionDuration || null,
189
+ signLanguageSrc: track.signLanguageSrc || null
190
+ };
191
+
192
+ this.player = new this.PlayerClass(mediaElement, playerOptions);
193
+
194
+ // Re-register playlist manager
195
+ this.player.playlistManager = this;
196
+
197
+ // Wait for player to be ready
198
+ await new Promise(resolve => {
199
+ this.player.on('ready', resolve);
200
+ });
201
+
202
+ // Re-attach event listeners
203
+ this.player.on('ended', this.handleTrackEnd);
204
+ this.player.on('error', this.handleTrackError);
205
+
206
+ // Re-attach all playlist UI elements to the new player's container
207
+ if (this.player.container) {
208
+ // Track artwork goes before video wrapper
209
+ if (this.trackArtworkElement) {
210
+ const videoWrapper = this.player.container.querySelector('.vidply-video-wrapper');
211
+ if (videoWrapper) {
212
+ this.player.container.insertBefore(this.trackArtworkElement, videoWrapper);
213
+ } else {
214
+ this.player.container.appendChild(this.trackArtworkElement);
215
+ }
216
+ }
217
+ // Track info
218
+ if (this.trackInfoElement) {
219
+ this.player.container.appendChild(this.trackInfoElement);
220
+ }
221
+ // Navigation feedback (screen reader only)
222
+ if (this.navigationFeedback) {
223
+ this.player.container.appendChild(this.navigationFeedback);
224
+ }
225
+ // Playlist panel
226
+ if (this.playlistPanel) {
227
+ this.player.container.appendChild(this.playlistPanel);
228
+ }
229
+ }
230
+
231
+ // Update container reference
232
+ this.container = this.player.container;
233
+
234
+ // Update controls (adds playlist prev/next buttons)
235
+ this.updatePlayerControls();
236
+
237
+ // Restore tracks data (we kept it during recreation)
238
+ this.tracks = savedTracks;
239
+ this.currentIndex = savedIndex;
240
+
241
+ // Update playlist UI to reflect current state
242
+ this.updatePlaylistUI();
243
+
244
+ // Restore playlist panel visibility
245
+ this.isPanelVisible = wasVisible;
246
+ if (this.playlistPanel) {
247
+ this.playlistPanel.style.display = wasVisible ? '' : 'none';
248
+ }
249
+
250
+ // For external renderers, load the track via player.load()
251
+ // For HTML5, the source is already set on the element
252
+ if (isExternalRenderer) {
253
+ this.player.load({
254
+ src: track.src,
255
+ type: track.type,
256
+ poster: track.poster,
257
+ tracks: track.tracks || [],
258
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
259
+ signLanguageSrc: track.signLanguageSrc || null
260
+ });
261
+ } else {
262
+ // For HTML5 media, also load to set up accessibility features
263
+ this.player.load({
264
+ src: track.src,
265
+ type: track.type,
266
+ poster: track.poster,
267
+ tracks: track.tracks || [],
268
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
269
+ signLanguageSrc: track.signLanguageSrc || null
270
+ });
271
+ }
272
+
273
+ // Auto-play if requested
274
+ if (autoPlay) {
275
+ setTimeout(() => {
276
+ this.player.play();
277
+ }, 100);
278
+ }
279
+
280
+ return true;
281
+ }
282
+
283
+ init() {
284
+ // Listen for track end
285
+ this.player.on('ended', this.handleTrackEnd);
286
+ this.player.on('error', this.handleTrackError);
287
+
288
+ // Listen for playback state changes to show/hide playlist in fullscreen
289
+ this.player.on('play', this.handlePlaybackStateChange.bind(this));
290
+ this.player.on('pause', this.handlePlaybackStateChange.bind(this));
291
+ this.player.on('ended', this.handlePlaybackStateChange.bind(this));
292
+ // Use fullscreenchange event which is what the player actually emits
293
+ this.player.on('fullscreenchange', this.handleFullscreenChange.bind(this));
294
+
295
+ // Listen for audio description state changes to update duration displays
296
+ this.player.on('audiodescriptionenabled', this.handleAudioDescriptionChange.bind(this));
297
+ this.player.on('audiodescriptiondisabled', this.handleAudioDescriptionChange.bind(this));
298
+
299
+ // Create UI if needed
300
+ if (this.options.showPanel) {
301
+ this.createUI();
302
+ }
303
+
304
+ // Check for data-playlist attribute on player container (only if tracks weren't provided in options)
305
+ if (this.tracks.length === 0 && this.initialTracks.length === 0) {
306
+ this.loadPlaylistFromAttribute();
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Load playlist from data-playlist attribute if present
312
+ */
313
+ loadPlaylistFromAttribute() {
314
+ // Check the original wrapper element for data-playlist
315
+ // Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
316
+ // So we need to go up 3 levels
317
+ if (!this.player.element || !this.player.element.parentElement) {
318
+ console.log('VidPly Playlist: No player element found');
319
+ return;
320
+ }
321
+
322
+ const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
323
+ const playerContainer = videoWrapper.parentElement; // .vidply-player
324
+ const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
325
+
326
+ if (!originalElement) {
327
+ console.log('VidPly Playlist: No original element found');
328
+ return;
329
+ }
330
+
331
+ // Load playlist options from data attributes
332
+ this.loadOptionsFromAttributes(originalElement);
333
+
334
+ const playlistData = originalElement.getAttribute('data-playlist');
335
+ if (!playlistData) {
336
+ console.log('VidPly Playlist: No data-playlist attribute found');
337
+ return;
338
+ }
339
+
340
+ console.log('VidPly Playlist: Found data-playlist attribute, parsing...');
341
+ try {
342
+ const tracks = JSON.parse(playlistData);
343
+ if (Array.isArray(tracks) && tracks.length > 0) {
344
+ console.log(`VidPly Playlist: Loaded ${tracks.length} tracks from data-playlist`);
345
+ this.loadPlaylist(tracks);
346
+ } else {
347
+ console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
348
+ }
349
+ } catch (error) {
350
+ console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Load playlist options from data attributes
356
+ * @param {HTMLElement} element - Element to read attributes from
357
+ */
358
+ loadOptionsFromAttributes(element) {
359
+ // data-playlist-auto-advance
360
+ const autoAdvance = element.getAttribute('data-playlist-auto-advance');
361
+ if (autoAdvance !== null) {
362
+ this.options.autoAdvance = autoAdvance === 'true';
363
+ }
364
+
365
+ // data-playlist-auto-play-first
366
+ const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
367
+ if (autoPlayFirst !== null) {
368
+ this.options.autoPlayFirst = autoPlayFirst === 'true';
369
+ }
370
+
371
+ // data-playlist-loop
372
+ const loop = element.getAttribute('data-playlist-loop');
373
+ if (loop !== null) {
374
+ this.options.loop = loop === 'true';
375
+ }
376
+
377
+ // data-playlist-show-panel
378
+ const showPanel = element.getAttribute('data-playlist-show-panel');
379
+ if (showPanel !== null) {
380
+ this.options.showPanel = showPanel === 'true';
381
+ }
382
+
383
+ console.log('VidPly Playlist: Options from attributes:', this.options);
384
+ }
385
+
386
+ /**
387
+ * Update player controls to add playlist navigation buttons
388
+ */
389
+ updatePlayerControls() {
390
+ if (!this.player.controlBar) return;
391
+
392
+ const controlBar = this.player.controlBar;
393
+
394
+ // Clear existing controls content (except the element itself)
395
+ controlBar.element.innerHTML = '';
396
+
397
+ // Recreate controls with playlist buttons now available
398
+ controlBar.createControls();
399
+
400
+ // Reattach events for the new controls
401
+ controlBar.attachEvents();
402
+ controlBar.setupAutoHide();
403
+ }
404
+
405
+ /**
406
+ * Load a playlist
407
+ * @param {Array} tracks - Array of track objects
408
+ */
409
+ loadPlaylist(tracks) {
410
+ this.tracks = tracks;
411
+ this.currentIndex = -1;
412
+
413
+ // Add playlist class to container
414
+ if (this.container) {
415
+ this.container.classList.add('vidply-has-playlist');
416
+ }
417
+
418
+ // Update UI
419
+ if (this.playlistPanel) {
420
+ this.renderPlaylist();
421
+ }
422
+
423
+ // Auto-play first track (if enabled)
424
+ if (tracks.length > 0) {
425
+ if (this.options.autoPlayFirst) {
426
+ this.play(0);
427
+ } else {
428
+ // Load first track without playing
429
+ this.loadTrack(0);
430
+ }
431
+ }
432
+
433
+ // Update visibility based on current state
434
+ this.updatePlaylistVisibilityInFullscreen();
435
+ }
436
+
437
+ /**
438
+ * Load a track without playing
439
+ * @param {number} index - Track index
440
+ */
441
+ async loadTrack(index) {
442
+ if (index < 0 || index >= this.tracks.length) {
443
+ console.warn('VidPly Playlist: Invalid track index', index);
444
+ return;
445
+ }
446
+
447
+ const track = this.tracks[index];
448
+
449
+ // Set guard flag to prevent cascade of next() calls during track change
450
+ this.isChangingTrack = true;
451
+
452
+ // Update current index
453
+ this.currentIndex = index;
454
+
455
+ // Check if we should recreate the player for this track type
456
+ if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
457
+ const currentMediaType = this.player ?
458
+ (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
459
+ const newMediaType = this.getTrackMediaType(track);
460
+ const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
461
+
462
+ // Recreate if element type is different
463
+ if (currentMediaType !== newElementType) {
464
+ await this.recreatePlayerForTrack(track, false);
465
+ // Update UI after recreation
466
+ this.updateTrackInfo(track);
467
+ this.updatePlaylistUI();
468
+
469
+ // Emit event
470
+ this.player.emit('playlisttrackchange', {
471
+ index: index,
472
+ item: track,
473
+ total: this.tracks.length
474
+ });
475
+
476
+ // Clear guard flag
477
+ setTimeout(() => {
478
+ this.isChangingTrack = false;
479
+ }, 150);
480
+ return;
481
+ }
482
+ }
483
+
484
+ // Load track into player (normal path)
485
+ this.player.load({
486
+ src: track.src,
487
+ type: track.type,
488
+ poster: track.poster,
489
+ tracks: track.tracks || [],
490
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
491
+ signLanguageSrc: track.signLanguageSrc || null
492
+ });
493
+
494
+ // Update UI
495
+ this.updateTrackInfo(track);
496
+ this.updatePlaylistUI();
497
+
498
+ // Emit event
499
+ this.player.emit('playlisttrackchange', {
500
+ index: index,
501
+ item: track,
502
+ total: this.tracks.length
503
+ });
504
+
505
+ // Clear guard flag after a short delay to ensure track is loaded
506
+ setTimeout(() => {
507
+ this.isChangingTrack = false;
508
+ }, 150);
509
+ }
510
+
511
+ /**
512
+ * Play a specific track
513
+ * @param {number} index - Track index
514
+ * @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
515
+ */
516
+ async play(index, userInitiated = false) {
517
+ if (index < 0 || index >= this.tracks.length) {
518
+ console.warn('VidPly Playlist: Invalid track index', index);
519
+ return;
520
+ }
521
+
522
+ const track = this.tracks[index];
523
+
524
+ // Set guard flag to prevent cascade of next() calls during track change
525
+ this.isChangingTrack = true;
526
+
527
+ // Update current index
528
+ this.currentIndex = index;
529
+
530
+ // Check if we should recreate the player for this track type
531
+ if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
532
+ const currentMediaType = this.player ?
533
+ (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
534
+ const newMediaType = this.getTrackMediaType(track);
535
+ const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
536
+
537
+ // Recreate if element type is different
538
+ if (currentMediaType !== newElementType) {
539
+ await this.recreatePlayerForTrack(track, true); // true = autoPlay
540
+ // Update UI after recreation
541
+ this.updateTrackInfo(track);
542
+ this.updatePlaylistUI();
543
+
544
+ // Emit event
545
+ this.player.emit('playlisttrackchange', {
546
+ index: index,
547
+ item: track,
548
+ total: this.tracks.length
549
+ });
550
+
551
+ // Clear guard flag
552
+ setTimeout(() => {
553
+ this.isChangingTrack = false;
554
+ }, 150);
555
+ return;
556
+ }
557
+ }
558
+
559
+ // Load track into player (normal path)
560
+ this.player.load({
561
+ src: track.src,
562
+ type: track.type,
563
+ poster: track.poster,
564
+ tracks: track.tracks || [],
565
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
566
+ signLanguageSrc: track.signLanguageSrc || null
567
+ });
568
+
569
+ // Update UI
570
+ this.updateTrackInfo(track);
571
+ this.updatePlaylistUI();
572
+
573
+ // Emit event
574
+ this.player.emit('playlisttrackchange', {
575
+ index: index,
576
+ item: track,
577
+ total: this.tracks.length
578
+ });
579
+
580
+ // Auto-play and clear guard flag after playback starts
581
+ setTimeout(() => {
582
+ this.player.play();
583
+ // Clear guard flag after a short delay to ensure track has started
584
+ setTimeout(() => {
585
+ this.isChangingTrack = false;
586
+ }, 50);
587
+ }, 100);
588
+ }
589
+
590
+ /**
591
+ * Play next track
592
+ */
593
+ next() {
594
+ let nextIndex = this.currentIndex + 1;
595
+
596
+ if (nextIndex >= this.tracks.length) {
597
+ if (this.options.loop) {
598
+ nextIndex = 0;
599
+ } else {
600
+ return;
601
+ }
602
+ }
603
+
604
+ this.play(nextIndex);
605
+ }
606
+
607
+ /**
608
+ * Play previous track
609
+ */
610
+ previous() {
611
+ let prevIndex = this.currentIndex - 1;
612
+
613
+ if (prevIndex < 0) {
614
+ if (this.options.loop) {
615
+ prevIndex = this.tracks.length - 1;
616
+ } else {
617
+ return;
618
+ }
619
+ }
620
+
621
+ this.play(prevIndex);
622
+ }
623
+
624
+ /**
625
+ * Handle track end
626
+ */
627
+ handleTrackEnd() {
628
+ // Don't auto-advance if we're already in the process of changing tracks
629
+ // This prevents a cascade of next() calls when loading a new track triggers an 'ended' event
630
+ if (this.isChangingTrack) {
631
+ return;
632
+ }
633
+
634
+ if (this.options.autoAdvance) {
635
+ this.next();
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Check if a source URL requires an external renderer
641
+ * @param {string} src - Source URL
642
+ * @returns {boolean}
643
+ */
644
+ isExternalRendererUrl(src) {
645
+ if (!src) return false;
646
+ return src.includes('youtube.com') ||
647
+ src.includes('youtu.be') ||
648
+ src.includes('vimeo.com') ||
649
+ src.includes('soundcloud.com') ||
650
+ src.includes('api.soundcloud.com') ||
651
+ src.includes('.m3u8');
652
+ }
653
+
654
+ /**
655
+ * Handle track error
656
+ */
657
+ handleTrackError(e) {
658
+ // Don't auto-advance for external renderer tracks
659
+ // External renderers (YouTube, Vimeo, SoundCloud, HLS) may trigger HTML5 errors
660
+ // that should be ignored since the external renderer handles playback
661
+ const currentTrack = this.getCurrentTrack();
662
+ if (currentTrack && currentTrack.src && this.isExternalRendererUrl(currentTrack.src)) {
663
+ // Silently ignore errors for external renderer tracks
664
+ return;
665
+ }
666
+
667
+ // Don't auto-advance if we're in the process of changing tracks
668
+ // This prevents a cascade of next() calls when switching between renderer types
669
+ if (this.isChangingTrack) {
670
+ return;
671
+ }
672
+
673
+ console.error('VidPly Playlist: Track error', e);
674
+
675
+ // Try next track
676
+ if (this.options.autoAdvance) {
677
+ setTimeout(() => {
678
+ this.next();
679
+ }, 1000);
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Handle playback state changes (for fullscreen playlist visibility)
685
+ */
686
+ handlePlaybackStateChange() {
687
+ this.updatePlaylistVisibilityInFullscreen();
688
+ }
689
+
690
+ /**
691
+ * Handle fullscreen state changes
692
+ */
693
+ handleFullscreenChange() {
694
+ // Use a small delay to ensure fullscreen state is fully applied
695
+ setTimeout(() => {
696
+ this.updatePlaylistVisibilityInFullscreen();
697
+ }, 50);
698
+ }
699
+
700
+ /**
701
+ * Handle audio description state changes
702
+ * Updates duration displays to show audio-described version duration when AD is enabled
703
+ */
704
+ handleAudioDescriptionChange() {
705
+ const currentTrack = this.getCurrentTrack();
706
+ if (!currentTrack) return;
707
+
708
+ // Update the track info display with the appropriate duration
709
+ this.updateTrackInfo(currentTrack);
710
+
711
+ // Update the playlist UI to reflect duration changes (aria-labels)
712
+ this.updatePlaylistUI();
713
+
714
+ // Update visual duration elements in playlist panel
715
+ this.updatePlaylistDurations();
716
+ }
717
+
718
+ /**
719
+ * Update the visual duration displays in the playlist panel
720
+ * Called when audio description state changes
721
+ */
722
+ updatePlaylistDurations() {
723
+ if (!this.playlistPanel) return;
724
+
725
+ const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
726
+
727
+ items.forEach((item, index) => {
728
+ const track = this.tracks[index];
729
+ if (!track) return;
730
+
731
+ const effectiveDuration = this.getEffectiveDuration(track);
732
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
733
+
734
+ // Update duration badge on thumbnail (if exists)
735
+ const durationBadge = item.querySelector('.vidply-playlist-duration-badge');
736
+ if (durationBadge) {
737
+ durationBadge.textContent = trackDuration;
738
+ }
739
+
740
+ // Update inline duration (if exists)
741
+ const inlineDuration = item.querySelector('.vidply-playlist-item-duration');
742
+ if (inlineDuration) {
743
+ inlineDuration.textContent = trackDuration;
744
+ }
745
+ });
746
+ }
747
+
748
+ /**
749
+ * Get the effective duration for a track based on audio description state
750
+ * @param {Object} track - Track object
751
+ * @returns {number|null} - Duration in seconds or null if not available
752
+ */
753
+ getEffectiveDuration(track) {
754
+ if (!track) return null;
755
+
756
+ const isAudioDescriptionEnabled = this.player.state.audioDescriptionEnabled;
757
+
758
+ // If audio description is enabled and track has audioDescriptionDuration, use it
759
+ if (isAudioDescriptionEnabled && track.audioDescriptionDuration) {
760
+ return track.audioDescriptionDuration;
761
+ }
762
+
763
+ // Otherwise use regular duration
764
+ return track.duration || null;
765
+ }
766
+
767
+ /**
768
+ * Update playlist visibility based on fullscreen and playback state
769
+ * In fullscreen: show when paused/not started, hide when playing
770
+ * Outside fullscreen: respect original panel visibility setting
771
+ */
772
+ updatePlaylistVisibilityInFullscreen() {
773
+ if (!this.playlistPanel || !this.tracks.length) return;
774
+
775
+ const isFullscreen = this.player.state.fullscreen;
776
+ const isPlaying = this.player.state.playing;
777
+
778
+ if (isFullscreen) {
779
+ // In fullscreen: show only when not playing (paused or not started)
780
+ // Check playing state explicitly since paused might not be set initially
781
+ if (!isPlaying) {
782
+ this.playlistPanel.classList.add('vidply-playlist-fullscreen-visible');
783
+ this.playlistPanel.style.display = 'block';
784
+ } else {
785
+ this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
786
+ // Add a smooth fade out with delay to match CSS transition
787
+ setTimeout(() => {
788
+ // Double-check state hasn't changed before hiding
789
+ if (this.player.state.playing && this.player.state.fullscreen) {
790
+ this.playlistPanel.style.display = 'none';
791
+ }
792
+ }, 300); // Match CSS transition duration
793
+ }
794
+ } else {
795
+ // Outside fullscreen: restore original behavior
796
+ this.playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
797
+ if (this.isPanelVisible && this.tracks.length > 0) {
798
+ this.playlistPanel.style.display = 'block';
799
+ } else {
800
+ this.playlistPanel.style.display = 'none';
801
+ }
802
+ }
803
+ }
804
+
805
+ /**
806
+ * Create playlist UI
807
+ */
808
+ createUI() {
809
+ // Find player container
810
+ this.container = this.player.container;
811
+
812
+ if (!this.container) {
813
+ console.warn('VidPly Playlist: No container found');
814
+ return;
815
+ }
816
+
817
+ // Create track artwork element (shows album art/poster for audio playlists)
818
+ // Only create for audio players
819
+ if (this.player.element.tagName === 'AUDIO') {
820
+ this.trackArtworkElement = DOMUtils.createElement('div', {
821
+ className: 'vidply-track-artwork',
822
+ attributes: {
823
+ 'aria-hidden': 'true'
824
+ }
825
+ });
826
+ this.trackArtworkElement.style.display = 'none';
827
+
828
+ // Insert before video wrapper
829
+ const videoWrapper = this.container.querySelector('.vidply-video-wrapper');
830
+ if (videoWrapper) {
831
+ this.container.insertBefore(this.trackArtworkElement, videoWrapper);
832
+ } else {
833
+ this.container.appendChild(this.trackArtworkElement);
834
+ }
835
+ }
836
+
837
+ // Create track info element (shows current track)
838
+ this.trackInfoElement = DOMUtils.createElement('div', {
839
+ className: 'vidply-track-info',
840
+ attributes: {
841
+ role: 'status'
842
+ }
843
+ });
844
+ this.trackInfoElement.style.display = 'none';
845
+
846
+ this.container.appendChild(this.trackInfoElement);
847
+
848
+ // Create navigation feedback live region
849
+ this.navigationFeedback = DOMUtils.createElement('div', {
850
+ className: 'vidply-sr-only',
851
+ attributes: {
852
+ role: 'status',
853
+ 'aria-live': 'polite',
854
+ 'aria-atomic': 'true'
855
+ }
856
+ });
857
+ this.container.appendChild(this.navigationFeedback);
858
+
859
+ // Create playlist panel with proper landmark
860
+ this.playlistPanel = DOMUtils.createElement('div', {
861
+ className: 'vidply-playlist-panel',
862
+ attributes: {
863
+ id: `${this.uniqueId}-panel`,
864
+ role: 'region',
865
+ 'aria-label': i18n.t('playlist.title'),
866
+ 'aria-labelledby': `${this.uniqueId}-heading`
867
+ }
868
+ });
869
+ this.playlistPanel.style.display = this.isPanelVisible ? 'none' : 'none'; // Will be shown when playlist is loaded
870
+
871
+ this.container.appendChild(this.playlistPanel);
872
+ }
873
+
874
+ /**
875
+ * Update track info display
876
+ */
877
+ updateTrackInfo(track) {
878
+ if (!this.trackInfoElement) return;
879
+
880
+ const trackNumber = this.currentIndex + 1;
881
+ const totalTracks = this.tracks.length;
882
+ const trackTitle = track.title || i18n.t('playlist.untitled');
883
+ const trackArtist = track.artist || '';
884
+
885
+ // Use effective duration (audio description duration when AD is enabled)
886
+ const effectiveDuration = this.getEffectiveDuration(track);
887
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
888
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
889
+
890
+ // Screen reader announcement - include duration if available
891
+ const artistPart = trackArtist ? i18n.t('playlist.by') + trackArtist : '';
892
+ const durationPart = trackDurationReadable ? `. ${trackDurationReadable}` : '';
893
+ const announcement = i18n.t('playlist.nowPlaying', {
894
+ current: trackNumber,
895
+ total: totalTracks,
896
+ title: trackTitle,
897
+ artist: artistPart
898
+ }) + durationPart;
899
+
900
+ const trackOfText = i18n.t('playlist.trackOf', {
901
+ current: trackNumber,
902
+ total: totalTracks
903
+ });
904
+
905
+ // Build duration HTML if available
906
+ const durationHtml = trackDuration
907
+ ? `<span class="vidply-track-duration" aria-hidden="true">${DOMUtils.escapeHTML(trackDuration)}</span>`
908
+ : '';
909
+
910
+ // Get description if available
911
+ const trackDescription = track.description || '';
912
+
913
+ this.trackInfoElement.innerHTML = `
914
+ <span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
915
+ <div class="vidply-track-header" aria-hidden="true">
916
+ <span class="vidply-track-number">${DOMUtils.escapeHTML(trackOfText)}</span>
917
+ ${durationHtml}
918
+ </div>
919
+ <div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
920
+ ${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
921
+ ${trackDescription ? `<div class="vidply-track-description" aria-hidden="true">${DOMUtils.escapeHTML(trackDescription)}</div>` : ''}
922
+ `;
923
+
924
+ this.trackInfoElement.style.display = 'block';
925
+
926
+ // Update track artwork if available (for audio playlists)
927
+ this.updateTrackArtwork(track);
928
+ }
929
+
930
+ /**
931
+ * Update track artwork display (for audio playlists)
932
+ */
933
+ updateTrackArtwork(track) {
934
+ if (!this.trackArtworkElement) return;
935
+
936
+ // If track has a poster/artwork, show it
937
+ if (track.poster) {
938
+ this.trackArtworkElement.style.backgroundImage = `url(${track.poster})`;
939
+ this.trackArtworkElement.style.display = 'block';
940
+ } else {
941
+ // No artwork available, hide the element
942
+ this.trackArtworkElement.style.display = 'none';
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Render playlist
948
+ */
949
+ renderPlaylist() {
950
+ if (!this.playlistPanel) return;
951
+
952
+ // Clear existing
953
+ this.playlistPanel.innerHTML = '';
954
+
955
+ // Create header
956
+ const header = DOMUtils.createElement('h2', {
957
+ className: 'vidply-playlist-header',
958
+ attributes: {
959
+ id: `${this.uniqueId}-heading`
960
+ }
961
+ });
962
+ header.textContent = `${i18n.t('playlist.title')} (${this.tracks.length})`;
963
+ this.playlistPanel.appendChild(header);
964
+
965
+ // Add keyboard instructions (visually hidden)
966
+ const instructions = DOMUtils.createElement('div', {
967
+ className: 'vidply-sr-only',
968
+ attributes: {
969
+ id: `${this.uniqueId}-keyboard-instructions`
970
+ }
971
+ });
972
+ instructions.textContent = i18n.t('playlist.keyboardInstructions');
973
+ this.playlistPanel.appendChild(instructions);
974
+
975
+ // Create list (proper ul element)
976
+ const list = DOMUtils.createElement('ul', {
977
+ className: 'vidply-playlist-list',
978
+ attributes: {
979
+ role: 'listbox',
980
+ 'aria-labelledby': `${this.uniqueId}-heading`,
981
+ 'aria-describedby': `${this.uniqueId}-keyboard-instructions`
982
+ }
983
+ });
984
+
985
+ this.tracks.forEach((track, index) => {
986
+ const item = this.createPlaylistItem(track, index);
987
+ list.appendChild(item);
988
+ });
989
+
990
+ this.playlistPanel.appendChild(list);
991
+
992
+ // Show panel if it should be visible
993
+ if (this.isPanelVisible) {
994
+ this.playlistPanel.style.display = 'block';
995
+ }
996
+ }
997
+
998
+ /**
999
+ * Create playlist item element
1000
+ */
1001
+ createPlaylistItem(track, index) {
1002
+ const trackPosition = i18n.t('playlist.trackOf', {
1003
+ current: index + 1,
1004
+ total: this.tracks.length
1005
+ });
1006
+ const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1007
+ const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1008
+
1009
+ // Use effective duration (audio description duration when AD is enabled)
1010
+ const effectiveDuration = this.getEffectiveDuration(track);
1011
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration) : '';
1012
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
1013
+ const isActive = index === this.currentIndex;
1014
+
1015
+ // Build accessible label for screen readers
1016
+ // With role="option" and aria-checked, screen reader will announce selection state
1017
+ // Position is already announced via aria-posinset/aria-setsize
1018
+ // Format: "Title by Artist. 3 minutes, 45 seconds."
1019
+ let ariaLabel = `${trackTitle}${trackArtist}`;
1020
+ if (trackDurationReadable) {
1021
+ ariaLabel += `. ${trackDurationReadable}`;
1022
+ }
1023
+
1024
+ // Create list item container (semantic HTML)
1025
+ const item = DOMUtils.createElement('li', {
1026
+ className: isActive ? 'vidply-playlist-item vidply-playlist-item-active' : 'vidply-playlist-item',
1027
+ attributes: {
1028
+ 'data-playlist-index': index
1029
+ }
1030
+ });
1031
+
1032
+ // Create button wrapper for interactive content
1033
+ const button = DOMUtils.createElement('button', {
1034
+ className: 'vidply-playlist-item-button',
1035
+ attributes: {
1036
+ type: 'button',
1037
+ role: 'option',
1038
+ tabIndex: index === 0 ? 0 : -1, // Only first item is in tab order initially
1039
+ 'aria-label': ariaLabel,
1040
+ 'aria-posinset': index + 1,
1041
+ 'aria-setsize': this.tracks.length,
1042
+ 'aria-checked': isActive ? 'true' : 'false'
1043
+ }
1044
+ });
1045
+
1046
+ // Add aria-current if active
1047
+ if (isActive) {
1048
+ button.setAttribute('aria-current', 'true');
1049
+ button.setAttribute('tabIndex', '0'); // Active item should always be tabbable
1050
+ }
1051
+
1052
+ // Thumbnail container with optional duration badge
1053
+ const thumbnailContainer = DOMUtils.createElement('span', {
1054
+ className: 'vidply-playlist-thumbnail-container',
1055
+ attributes: {
1056
+ 'aria-hidden': 'true'
1057
+ }
1058
+ });
1059
+
1060
+ // Thumbnail or icon
1061
+ const thumbnail = DOMUtils.createElement('span', {
1062
+ className: 'vidply-playlist-thumbnail'
1063
+ });
1064
+
1065
+ if (track.poster) {
1066
+ thumbnail.style.backgroundImage = `url(${track.poster})`;
1067
+ } else {
1068
+ // Show music/speaker icon for audio tracks
1069
+ const icon = createIconElement('music');
1070
+ icon.classList.add('vidply-playlist-thumbnail-icon');
1071
+ thumbnail.appendChild(icon);
1072
+ }
1073
+
1074
+ thumbnailContainer.appendChild(thumbnail);
1075
+
1076
+ // Duration badge on thumbnail (like YouTube) - only show if there's a poster
1077
+ if (trackDuration && track.poster) {
1078
+ const durationBadge = DOMUtils.createElement('span', {
1079
+ className: 'vidply-playlist-duration-badge'
1080
+ });
1081
+ durationBadge.textContent = trackDuration;
1082
+ thumbnailContainer.appendChild(durationBadge);
1083
+ }
1084
+
1085
+ button.appendChild(thumbnailContainer);
1086
+
1087
+ // Info section (title, artist, description)
1088
+ const info = DOMUtils.createElement('span', {
1089
+ className: 'vidply-playlist-item-info',
1090
+ attributes: {
1091
+ 'aria-hidden': 'true'
1092
+ }
1093
+ });
1094
+
1095
+ // Title row with optional inline duration (for when no thumbnail)
1096
+ const titleRow = DOMUtils.createElement('span', {
1097
+ className: 'vidply-playlist-item-title-row'
1098
+ });
1099
+
1100
+ const title = DOMUtils.createElement('span', {
1101
+ className: 'vidply-playlist-item-title'
1102
+ });
1103
+ title.textContent = trackTitle;
1104
+ titleRow.appendChild(title);
1105
+
1106
+ // Inline duration (shown when no poster/thumbnail)
1107
+ if (trackDuration && !track.poster) {
1108
+ const inlineDuration = DOMUtils.createElement('span', {
1109
+ className: 'vidply-playlist-item-duration'
1110
+ });
1111
+ inlineDuration.textContent = trackDuration;
1112
+ titleRow.appendChild(inlineDuration);
1113
+ }
1114
+
1115
+ info.appendChild(titleRow);
1116
+
1117
+ // Artist
1118
+ if (track.artist) {
1119
+ const artist = DOMUtils.createElement('span', {
1120
+ className: 'vidply-playlist-item-artist'
1121
+ });
1122
+ artist.textContent = track.artist;
1123
+ info.appendChild(artist);
1124
+ }
1125
+
1126
+ // Description (truncated)
1127
+ if (track.description) {
1128
+ const description = DOMUtils.createElement('span', {
1129
+ className: 'vidply-playlist-item-description'
1130
+ });
1131
+ description.textContent = track.description;
1132
+ info.appendChild(description);
1133
+ }
1134
+
1135
+ button.appendChild(info);
1136
+
1137
+ // Play icon
1138
+ const playIcon = createIconElement('play');
1139
+ playIcon.classList.add('vidply-playlist-item-icon');
1140
+ playIcon.setAttribute('aria-hidden', 'true');
1141
+ button.appendChild(playIcon);
1142
+
1143
+ // Click handler
1144
+ button.addEventListener('click', () => {
1145
+ this.play(index, true); // User-initiated
1146
+ });
1147
+
1148
+ // Keyboard handler
1149
+ button.addEventListener('keydown', (e) => {
1150
+ this.handlePlaylistItemKeydown(e, index);
1151
+ });
1152
+
1153
+ // Append button to list item
1154
+ item.appendChild(button);
1155
+
1156
+ return item;
1157
+ }
1158
+
1159
+ /**
1160
+ * Handle keyboard navigation in playlist items
1161
+ */
1162
+ handlePlaylistItemKeydown(e, index) {
1163
+ const buttons = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item-button'));
1164
+ let newIndex = -1;
1165
+ let announcement = '';
1166
+
1167
+ switch(e.key) {
1168
+ case 'Enter':
1169
+ case ' ':
1170
+ e.preventDefault();
1171
+ e.stopPropagation();
1172
+ this.play(index, true); // User-initiated
1173
+ return; // No need to move focus
1174
+
1175
+ case 'ArrowDown':
1176
+ e.preventDefault();
1177
+ e.stopPropagation();
1178
+ // Move to next item
1179
+ if (index < buttons.length - 1) {
1180
+ newIndex = index + 1;
1181
+ } else {
1182
+ // At the end, announce boundary
1183
+ announcement = i18n.t('playlist.endOfPlaylist', { current: buttons.length, total: buttons.length });
1184
+ }
1185
+ break;
1186
+
1187
+ case 'ArrowUp':
1188
+ e.preventDefault();
1189
+ e.stopPropagation();
1190
+ // Move to previous item
1191
+ if (index > 0) {
1192
+ newIndex = index - 1;
1193
+ } else {
1194
+ // At the beginning, announce boundary
1195
+ announcement = i18n.t('playlist.beginningOfPlaylist', { total: buttons.length });
1196
+ }
1197
+ break;
1198
+
1199
+ case 'PageDown':
1200
+ e.preventDefault();
1201
+ e.stopPropagation();
1202
+ // Move 5 items down (or to end)
1203
+ newIndex = Math.min(index + 5, buttons.length - 1);
1204
+ if (newIndex === buttons.length - 1 && index !== newIndex) {
1205
+ announcement = i18n.t('playlist.jumpedToLastTrack', { current: newIndex + 1, total: buttons.length });
1206
+ }
1207
+ break;
1208
+
1209
+ case 'PageUp':
1210
+ e.preventDefault();
1211
+ e.stopPropagation();
1212
+ // Move 5 items up (or to beginning)
1213
+ newIndex = Math.max(index - 5, 0);
1214
+ if (newIndex === 0 && index !== newIndex) {
1215
+ announcement = i18n.t('playlist.jumpedToFirstTrack', { total: buttons.length });
1216
+ }
1217
+ break;
1218
+
1219
+ case 'Home':
1220
+ e.preventDefault();
1221
+ e.stopPropagation();
1222
+ // Move to first item
1223
+ newIndex = 0;
1224
+ if (index !== 0) {
1225
+ announcement = i18n.t('playlist.firstTrack', { total: buttons.length });
1226
+ }
1227
+ break;
1228
+
1229
+ case 'End':
1230
+ e.preventDefault();
1231
+ e.stopPropagation();
1232
+ // Move to last item
1233
+ newIndex = buttons.length - 1;
1234
+ if (index !== buttons.length - 1) {
1235
+ announcement = i18n.t('playlist.lastTrack', { current: buttons.length, total: buttons.length });
1236
+ }
1237
+ break;
1238
+ }
1239
+
1240
+ // Update tab indices for roving tabindex pattern
1241
+ if (newIndex !== -1 && newIndex !== index) {
1242
+ buttons[index].setAttribute('tabIndex', '-1');
1243
+ buttons[newIndex].setAttribute('tabIndex', '0');
1244
+ buttons[newIndex].focus({ preventScroll: false });
1245
+
1246
+ // Scroll the focused item into view (same behavior as mouse interaction)
1247
+ const item = buttons[newIndex].closest('.vidply-playlist-item');
1248
+ if (item) {
1249
+ item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1250
+ }
1251
+ }
1252
+
1253
+ // Announce navigation feedback
1254
+ if (announcement && this.navigationFeedback) {
1255
+ this.navigationFeedback.textContent = announcement;
1256
+ // Clear after a short delay to allow for repeated announcements
1257
+ setTimeout(() => {
1258
+ if (this.navigationFeedback) {
1259
+ this.navigationFeedback.textContent = '';
1260
+ }
1261
+ }, 1000);
1262
+ }
1263
+ }
1264
+
1265
+ /**
1266
+ * Update playlist UI (highlight current track)
1267
+ */
1268
+ updatePlaylistUI() {
1269
+ if (!this.playlistPanel) return;
1270
+
1271
+ const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
1272
+ const buttons = this.playlistPanel.querySelectorAll('.vidply-playlist-item-button');
1273
+
1274
+ items.forEach((item, index) => {
1275
+ const button = buttons[index];
1276
+ if (!button) return;
1277
+
1278
+ const track = this.tracks[index];
1279
+ const trackPosition = i18n.t('playlist.trackOf', {
1280
+ current: index + 1,
1281
+ total: this.tracks.length
1282
+ });
1283
+ const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1284
+ const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1285
+
1286
+ // Use effective duration (audio description duration when AD is enabled)
1287
+ const effectiveDuration = this.getEffectiveDuration(track);
1288
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration) : '';
1289
+
1290
+ if (index === this.currentIndex) {
1291
+ // Update list item styling
1292
+ item.classList.add('vidply-playlist-item-active');
1293
+
1294
+ // Update button ARIA attributes
1295
+ button.setAttribute('aria-current', 'true');
1296
+ button.setAttribute('aria-checked', 'true');
1297
+ button.setAttribute('tabIndex', '0'); // Active item should be tabbable
1298
+
1299
+ // Simplified aria-label - status and actions are announced via ARIA roles
1300
+ let ariaLabel = `${trackTitle}${trackArtist}`;
1301
+ if (trackDurationReadable) {
1302
+ ariaLabel += `. ${trackDurationReadable}`;
1303
+ }
1304
+ button.setAttribute('aria-label', ariaLabel);
1305
+
1306
+ // Scroll into view within playlist panel (uses 'nearest' to minimize page scroll)
1307
+ item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1308
+ } else {
1309
+ // Update list item styling
1310
+ item.classList.remove('vidply-playlist-item-active');
1311
+
1312
+ // Update button ARIA attributes
1313
+ button.removeAttribute('aria-current');
1314
+ button.setAttribute('aria-checked', 'false');
1315
+ button.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
1316
+
1317
+ // Simplified aria-label - status and actions are announced via ARIA roles
1318
+ let ariaLabel = `${trackTitle}${trackArtist}`;
1319
+ if (trackDurationReadable) {
1320
+ ariaLabel += `. ${trackDurationReadable}`;
1321
+ }
1322
+ button.setAttribute('aria-label', ariaLabel);
1323
+ }
1324
+ });
1325
+ }
1326
+
1327
+ /**
1328
+ * Get current track
1329
+ */
1330
+ getCurrentTrack() {
1331
+ return this.tracks[this.currentIndex] || null;
1332
+ }
1333
+
1334
+ /**
1335
+ * Get playlist info
1336
+ */
1337
+ getPlaylistInfo() {
1338
+ return {
1339
+ currentIndex: this.currentIndex,
1340
+ totalTracks: this.tracks.length,
1341
+ currentTrack: this.getCurrentTrack(),
1342
+ hasNext: this.hasNext(),
1343
+ hasPrevious: this.hasPrevious()
1344
+ };
1345
+ }
1346
+
1347
+ /**
1348
+ * Check if there is a next track
1349
+ */
1350
+ hasNext() {
1351
+ if (this.options.loop) return true;
1352
+ return this.currentIndex < this.tracks.length - 1;
1353
+ }
1354
+
1355
+ /**
1356
+ * Check if there is a previous track
1357
+ */
1358
+ hasPrevious() {
1359
+ if (this.options.loop) return true;
1360
+ return this.currentIndex > 0;
1361
+ }
1362
+
1363
+ /**
1364
+ * Add track to playlist
1365
+ */
1366
+ addTrack(track) {
1367
+ this.tracks.push(track);
1368
+
1369
+ if (this.playlistPanel) {
1370
+ this.renderPlaylist();
1371
+ }
1372
+ }
1373
+
1374
+ /**
1375
+ * Remove track from playlist
1376
+ */
1377
+ removeTrack(index) {
1378
+ if (index < 0 || index >= this.tracks.length) return;
1379
+
1380
+ this.tracks.splice(index, 1);
1381
+
1382
+ // Adjust current index if needed
1383
+ if (index < this.currentIndex) {
1384
+ this.currentIndex--;
1385
+ } else if (index === this.currentIndex) {
1386
+ // Current track was removed, play next or stop
1387
+ if (this.currentIndex >= this.tracks.length) {
1388
+ this.currentIndex = this.tracks.length - 1;
1389
+ }
1390
+
1391
+ if (this.currentIndex >= 0) {
1392
+ this.play(this.currentIndex);
1393
+ }
1394
+ }
1395
+
1396
+ if (this.playlistPanel) {
1397
+ this.renderPlaylist();
1398
+ }
1399
+ }
1400
+
1401
+ /**
1402
+ * Clear playlist
1403
+ */
1404
+ clear() {
1405
+ this.tracks = [];
1406
+ this.currentIndex = -1;
1407
+
1408
+ if (this.playlistPanel) {
1409
+ this.playlistPanel.innerHTML = '';
1410
+ this.playlistPanel.style.display = 'none';
1411
+ }
1412
+
1413
+ if (this.trackInfoElement) {
1414
+ this.trackInfoElement.innerHTML = '';
1415
+ this.trackInfoElement.style.display = 'none';
1416
+ }
1417
+
1418
+ if (this.trackArtworkElement) {
1419
+ this.trackArtworkElement.style.backgroundImage = '';
1420
+ this.trackArtworkElement.style.display = 'none';
1421
+ }
1422
+ }
1423
+
1424
+ /**
1425
+ * Toggle playlist panel visibility
1426
+ * @param {boolean} show - Optional: force show (true) or hide (false)
1427
+ * @returns {boolean} - New visibility state
1428
+ */
1429
+ togglePanel(show) {
1430
+ if (!this.playlistPanel) return false;
1431
+
1432
+ // Determine new state
1433
+ const shouldShow = show !== undefined ? show : this.playlistPanel.style.display === 'none';
1434
+
1435
+ if (shouldShow) {
1436
+ this.playlistPanel.style.display = 'block';
1437
+ this.isPanelVisible = true;
1438
+
1439
+ // Focus first item if playlist has tracks
1440
+ if (this.tracks.length > 0) {
1441
+ setTimeout(() => {
1442
+ const firstItem = this.playlistPanel.querySelector('.vidply-playlist-item[tabindex="0"]');
1443
+ if (firstItem) {
1444
+ firstItem.focus({ preventScroll: true });
1445
+ }
1446
+ }, 100);
1447
+ }
1448
+
1449
+ // Update toggle button state if it exists
1450
+ if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1451
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'true');
1452
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'true');
1453
+ }
1454
+ } else {
1455
+ this.playlistPanel.style.display = 'none';
1456
+ this.isPanelVisible = false;
1457
+
1458
+ // Update toggle button state if it exists
1459
+ if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1460
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'false');
1461
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'false');
1462
+
1463
+ // Return focus to toggle button
1464
+ this.player.controlBar.controls.playlistToggle.focus({ preventScroll: true });
1465
+ }
1466
+ }
1467
+
1468
+ return this.isPanelVisible;
1469
+ }
1470
+
1471
+ /**
1472
+ * Show playlist panel
1473
+ */
1474
+ showPanel() {
1475
+ return this.togglePanel(true);
1476
+ }
1477
+
1478
+ /**
1479
+ * Hide playlist panel
1480
+ */
1481
+ hidePanel() {
1482
+ return this.togglePanel(false);
1483
+ }
1484
+
1485
+ /**
1486
+ * Destroy playlist manager
1487
+ */
1488
+ destroy() {
1489
+ // Remove event listeners
1490
+ this.player.off('ended', this.handleTrackEnd);
1491
+ this.player.off('error', this.handleTrackError);
1492
+
1493
+ // Remove UI
1494
+ if (this.trackArtworkElement) {
1495
+ this.trackArtworkElement.remove();
1496
+ }
1497
+
1498
+ if (this.trackInfoElement) {
1499
+ this.trackInfoElement.remove();
1500
+ }
1501
+
1502
+ if (this.playlistPanel) {
1503
+ this.playlistPanel.remove();
1504
+ }
1505
+
1506
+ // Clear data
1507
+ this.clear();
1508
+ }
1509
+ }
1510
+
1511
+ export default PlaylistManager;