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.
- package/README.md +708 -708
- package/dist/dev/{vidply.HLSRenderer-UMPUDSYL.js → vidply.HLSRenderer-5MJZR4D2.js} +14 -8
- package/dist/dev/{vidply.HLSRenderer-ENLZE4QS.js.map → vidply.HLSRenderer-5MJZR4D2.js.map} +2 -2
- package/dist/dev/vidply.esm.js +11 -2
- package/dist/dev/vidply.esm.js.map +2 -2
- package/dist/legacy/vidply.js +24 -8
- package/dist/legacy/vidply.js.map +2 -2
- package/dist/legacy/vidply.min.js +1 -1
- package/dist/legacy/vidply.min.meta.json +8 -8
- package/dist/prod/{vidply.HLSRenderer-3CG7BZKA.min.js → vidply.HLSRenderer-VWNJD2CB.min.js} +1 -1
- package/dist/prod/vidply.esm.min.js +9 -9
- package/dist/vidply.esm.min.meta.json +11 -11
- package/package.json +1 -1
- package/src/controls/ControlBar.js +16 -1
- package/src/core/Player.js +4873 -4868
- package/src/features/PlaylistManager.js +1511 -1511
- package/src/renderers/HLSRenderer.js +17 -7
- package/dist/dev/vidply.HLSRenderer-ENLZE4QS.js +0 -266
- package/dist/dev/vidply.HLSRenderer-UMPUDSYL.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-6SBDI6S2.js +0 -12
- package/dist/dev/vidply.HTML5Renderer-6SBDI6S2.js.map +0 -7
- package/dist/dev/vidply.chunk-BCOFCT6U.js +0 -246
- package/dist/dev/vidply.chunk-BCOFCT6U.js.map +0 -7
- package/dist/prod/vidply.HLSRenderer-CBXZ4RF2.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-MY7XDV7R.min.js +0 -6
- package/dist/prod/vidply.chunk-OXXPY2XB.min.js +0 -6
|
@@ -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;
|