vidply 1.2.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +974 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
  23. package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
  24. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  25. package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
  26. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
  27. package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
  28. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  30. package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  32. package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
  34. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
  35. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
  36. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
  37. package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
  38. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
  39. package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
  41. package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
  43. package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +648 -35
  45. package/dist/dev/vidply.esm.js.map +3 -3
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
  47. package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
  49. package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
  50. package/dist/legacy/vidply.js +1062 -34
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +127 -55
  54. package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  66. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  67. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  69. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  72. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  75. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  81. package/dist/vidply.css +5852 -5796
  82. package/dist/vidply.esm.min.meta.json +204 -149
  83. package/dist/vidply.min.css +1 -1
  84. package/package.json +11 -11
  85. package/src/controls/CaptionManager.ts +598 -598
  86. package/src/controls/ControlBar.ts +4191 -4159
  87. package/src/controls/KeyboardHelp.ts +326 -0
  88. package/src/controls/KeyboardManager.ts +387 -383
  89. package/src/controls/SettingsDialog.ts +488 -488
  90. package/src/controls/TranscriptManager.ts +2426 -2426
  91. package/src/core/AudioDescriptionManager.ts +863 -774
  92. package/src/core/DescriptionSpeechManager.ts +232 -0
  93. package/src/core/FloatingPlayerManager.ts +674 -674
  94. package/src/core/LazyInit.ts +80 -80
  95. package/src/core/MediaSessionManager.ts +341 -0
  96. package/src/core/MetadataAlertsManager.ts +514 -514
  97. package/src/core/Player.ts +2973 -2872
  98. package/src/core/PosterManager.ts +169 -169
  99. package/src/core/PseudoFullscreen.ts +179 -179
  100. package/src/core/ResponsiveManager.ts +231 -231
  101. package/src/core/ResumeManager.ts +236 -236
  102. package/src/core/SignLanguageManager.ts +1308 -1308
  103. package/src/core/ThemeManager.ts +157 -157
  104. package/src/features/PlaylistManager.ts +1838 -1827
  105. package/src/i18n/i18n.ts +223 -223
  106. package/src/i18n/languages/de.ts +244 -217
  107. package/src/i18n/languages/en.ts +245 -218
  108. package/src/i18n/languages/es.ts +241 -214
  109. package/src/i18n/languages/fr.ts +241 -214
  110. package/src/i18n/languages/ja.ts +241 -214
  111. package/src/icons/Icons.ts +168 -167
  112. package/src/index.ts +196 -193
  113. package/src/renderers/DASHRenderer.ts +1077 -1077
  114. package/src/renderers/HLSRenderer.ts +944 -944
  115. package/src/renderers/HTML5Renderer.ts +389 -389
  116. package/src/renderers/SoundCloudRenderer.ts +437 -437
  117. package/src/renderers/VimeoRenderer.ts +298 -298
  118. package/src/renderers/YouTubeRenderer.ts +290 -290
  119. package/src/styles/vidply.css +5852 -5796
  120. package/src/types/events.ts +128 -122
  121. package/src/types/globals.d.ts +313 -313
  122. package/src/types/options.ts +199 -182
  123. package/src/types/renderer.ts +58 -58
  124. package/src/utils/DOMUtils.ts +281 -281
  125. package/src/utils/DownloadInfo.ts +171 -171
  126. package/src/utils/DraggablePanel.ts +539 -539
  127. package/src/utils/DraggablePanelMenu.ts +195 -195
  128. package/src/utils/DraggableResizable.ts +997 -997
  129. package/src/utils/EventEmitter.ts +59 -59
  130. package/src/utils/FormUtils.ts +95 -95
  131. package/src/utils/MenuFactory.ts +483 -483
  132. package/src/utils/Sanitize.ts +70 -70
  133. package/src/utils/SettingsMenuFactory.ts +289 -289
  134. package/src/utils/StorageManager.ts +287 -287
  135. package/src/utils/TrackLabelUtils.ts +52 -52
  136. package/src/utils/UrlSafe.ts +69 -69
  137. package/src/utils/VideoFrameCapture.ts +114 -114
  138. package/src/utils/WindowComponents.ts +250 -250
  139. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  140. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  141. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  142. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  143. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  144. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  145. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  146. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  147. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  148. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  149. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  151. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  152. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  153. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  154. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  155. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  156. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  157. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  158. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  159. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  160. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  161. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  162. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  163. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  165. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  166. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  167. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
@@ -1,1827 +1,1838 @@
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
- import { sanitizePosterUrl, toCssBackgroundImage } from '../utils/UrlSafe.js';
11
- import { reducedMotionScrollOptions } from '../utils/PerformanceUtils.js';
12
- import type { Player } from '../core/Player.js';
13
-
14
- type PlaylistTextTrack = {
15
- src?: string;
16
- kind?: string;
17
- srclang?: string;
18
- label?: string;
19
- default?: boolean;
20
- describedSrc?: string;
21
- [key: string]: unknown;
22
- };
23
-
24
- type PlaylistTrack = {
25
- src?: string;
26
- type?: string;
27
- poster?: string;
28
- tracks?: PlaylistTextTrack[];
29
- audioDescriptionSrc?: string | null;
30
- audioDescriptionDuration?: number | string | null;
31
- signLanguageSrc?: string | null;
32
- signLanguageSources?: Record<string, string>;
33
- duration?: number | string | null;
34
- title?: string;
35
- artist?: string;
36
- description?: string;
37
- [key: string]: unknown;
38
- };
39
-
40
- /**
41
- * Construction signature for the host Player class. The playlist
42
- * manager receives this via options so it can recreate players when
43
- * tracks of incompatible media types (e.g. audio after video) load.
44
- */
45
- type PlayerConstructor = new (
46
- element: string | HTMLElement,
47
- options?: Record<string, unknown>
48
- ) => Player;
49
-
50
- /**
51
- * Internal options bag for the manager. Extra keys from the caller
52
- * (we accept `Record<string, unknown>`) are merged in via the
53
- * `[key: string]: unknown` index so consumers can pass through
54
- * additional player options without losing typing on the known ones.
55
- */
56
- interface PlaylistManagerOptions {
57
- autoAdvance: boolean;
58
- autoPlayFirst: boolean;
59
- loop: boolean;
60
- showPanel: boolean;
61
- recreatePlayers: boolean;
62
- hostElement?: HTMLElement | null;
63
- PlayerClass?: PlayerConstructor | null;
64
- tracks?: PlaylistTrack[];
65
- [key: string]: unknown;
66
- }
67
-
68
- // Static counter for unique IDs
69
- let playlistInstanceCounter = 0;
70
-
71
- export class PlaylistManager {
72
- player: Player;
73
- container: HTMLElement | null;
74
- currentIndex: number;
75
- hostElement: HTMLElement | null;
76
- initialTracks: PlaylistTrack[];
77
- instanceId: number;
78
- isChangingTrack: boolean;
79
- isPanelVisible: boolean;
80
- navigationFeedback: HTMLElement | null;
81
- options: PlaylistManagerOptions;
82
- PlayerClass: PlayerConstructor | null;
83
- playlistPanel: HTMLElement | null;
84
- trackArtworkElement: HTMLElement | null;
85
- trackInfoElement: HTMLElement | null;
86
- tracks: PlaylistTrack[];
87
- uniqueId: string;
88
-
89
- constructor(player: Player, options: Record<string, unknown> = {}) {
90
- this.player = player;
91
- this.tracks = [];
92
- this.initialTracks = Array.isArray(options.tracks) ? (options.tracks as PlaylistTrack[]) : [];
93
- this.currentIndex = -1;
94
-
95
- // Generate unique instance ID for this playlist
96
- this.instanceId = ++playlistInstanceCounter;
97
- this.uniqueId = `vidply-playlist-${this.instanceId}`;
98
-
99
- // Options. Spread the caller's bag last so we still support arbitrary
100
- // pass-through keys, but defaults stay typed.
101
- this.options = {
102
- ...options,
103
- autoAdvance: options.autoAdvance !== false, // Default true
104
- autoPlayFirst: options.autoPlayFirst !== false, // Default true - auto-play first track on load
105
- loop: Boolean(options.loop) || false,
106
- showPanel: options.showPanel !== false, // Default true
107
- recreatePlayers: Boolean(options.recreatePlayers) || false,
108
- };
109
-
110
- // UI elements
111
- this.container = null;
112
- this.playlistPanel = null;
113
- this.trackInfoElement = null;
114
- this.trackArtworkElement = null;
115
- this.navigationFeedback = null; // Live region for keyboard navigation feedback
116
- this.isPanelVisible = this.options.showPanel !== false;
117
-
118
- // Track change guard to prevent cascade of next() calls
119
- this.isChangingTrack = false;
120
-
121
- // Store the host element for player recreation
122
- this.hostElement = (options.hostElement as HTMLElement | null | undefined) ?? null;
123
- this.PlayerClass = (options.PlayerClass as PlayerConstructor | null | undefined) ?? null;
124
-
125
- // Bind methods once so the same reference is used for both on() and off().
126
- // Binding inline at registration time (e.g. `.bind(this)` in init()) would
127
- // create a fresh function each call that off() can never match, leaking the
128
- // listener after destroy().
129
- this.handleTrackEnd = this.handleTrackEnd.bind(this);
130
- this.handleTrackError = this.handleTrackError.bind(this);
131
- this.handlePlaybackStateChange = this.handlePlaybackStateChange.bind(this);
132
- this.handleFullscreenChange = this.handleFullscreenChange.bind(this);
133
- this.handleAudioDescriptionChange = this.handleAudioDescriptionChange.bind(this);
134
-
135
- // Register this playlist manager with the player
136
- this.player.playlistManager = this;
137
-
138
- // Initialize
139
- this.init();
140
-
141
- // Update controls to add playlist buttons
142
- this.updatePlayerControls();
143
-
144
- // Load tracks if provided in options (after UI is ready)
145
- if (this.initialTracks.length > 0) {
146
- this.loadPlaylist(this.initialTracks);
147
- }
148
- }
149
-
150
- /**
151
- * Determine the media type for a track
152
- * @param {Object} track - Track object
153
- * @returns {string} - 'audio', 'video', 'youtube', 'vimeo', 'soundcloud', 'hls', 'dash'
154
- */
155
- getTrackMediaType(track: PlaylistTrack) {
156
- const src = track.src || '';
157
-
158
- if (src.includes('youtube.com') || src.includes('youtu.be')) {
159
- return 'youtube';
160
- }
161
- if (src.includes('vimeo.com')) {
162
- return 'vimeo';
163
- }
164
- if (src.includes('soundcloud.com') || src.includes('api.soundcloud.com')) {
165
- return 'soundcloud';
166
- }
167
- if (src.includes('.m3u8')) {
168
- return 'hls';
169
- }
170
- if (src.includes('.mpd')) {
171
- return 'dash';
172
- }
173
- if (track.type && track.type.startsWith('audio/')) {
174
- return 'audio';
175
- }
176
- // Default to video for video types or unknown
177
- return 'video';
178
- }
179
-
180
- /**
181
- * Recreate the player with the appropriate element type for the track
182
- * @param {Object} track - Track to load
183
- * @param {boolean} autoPlay - Whether to auto-play after creation
184
- */
185
- async recreatePlayerForTrack(track: PlaylistTrack, autoPlay = false) {
186
- if (!this.hostElement || !this.PlayerClass) {
187
- console.warn('VidPly Playlist: Cannot recreate player - missing hostElement or PlayerClass');
188
- return false;
189
- }
190
-
191
- const mediaType = this.getTrackMediaType(track);
192
- // SoundCloud uses an iframe widget, so it doesn't need an audio element
193
- // Only local audio files need an actual <audio> element
194
- const elementType = (mediaType === 'audio') ? 'audio' : 'video';
195
-
196
- // Store playlist panel state
197
- const wasVisible = this.isPanelVisible;
198
- const savedTracks = [...this.tracks]; // Keep track data
199
- const savedIndex = this.currentIndex;
200
-
201
- // Detach all playlist UI elements from DOM (keep references)
202
- // These will be reattached to the new player container
203
- if (this.trackArtworkElement && this.trackArtworkElement.parentNode) {
204
- this.trackArtworkElement.parentNode.removeChild(this.trackArtworkElement);
205
- }
206
- if (this.trackInfoElement && this.trackInfoElement.parentNode) {
207
- this.trackInfoElement.parentNode.removeChild(this.trackInfoElement);
208
- }
209
- if (this.navigationFeedback && this.navigationFeedback.parentNode) {
210
- this.navigationFeedback.parentNode.removeChild(this.navigationFeedback);
211
- }
212
- if (this.playlistPanel && this.playlistPanel.parentNode) {
213
- this.playlistPanel.parentNode.removeChild(this.playlistPanel);
214
- }
215
-
216
- // Preserve existing player options so recreated players behave
217
- // consistently. We use a loose `Record<string, unknown>` because
218
- // PlayerOptions includes optional fields and an open string-indexed
219
- // bag of pass-through values that the spread flattens.
220
- const preservedPlayerOptions: Record<string, unknown> =
221
- this.player?.options ? { ...this.player.options } : {};
222
-
223
- // Remove event listeners before destroying
224
- if (this.player) {
225
- this.player.off('ended', this.handleTrackEnd);
226
- this.player.off('error', this.handleTrackError);
227
- // Detach the back-reference *before* destroying the player.
228
- // Player.destroy() cascade-destroys its `playlistManager`, but here
229
- // we're swapping the player out and the PlaylistManager owns a UI
230
- // (panel, track info, artwork) that must survive the swap. Without
231
- // this detach, the cascade would clear() the panel and the playlist
232
- // visually disappears when the user clicks an audio track in a
233
- // mixed playlist.
234
- this.player.playlistManager = null;
235
- this.player.destroy();
236
- }
237
-
238
- // Clear the host element
239
- this.hostElement.innerHTML = '';
240
-
241
- // Create new media element with appropriate type
242
- const mediaElement = document.createElement(elementType);
243
- // Respect configured preload (playlists should behave like single videos even with deferLoad)
244
- const preloadValue = (preservedPlayerOptions.preload as string | undefined) || 'metadata';
245
- mediaElement.setAttribute('preload', preloadValue);
246
-
247
- // For video elements with local media, set poster
248
- if (elementType === 'video' && track.poster &&
249
- (mediaType === 'video' || mediaType === 'hls' || mediaType === 'dash')) {
250
- mediaElement.setAttribute('poster', track.poster);
251
- }
252
-
253
- // For external renderers (YouTube, Vimeo, SoundCloud, HLS, DASH), don't add source
254
- // The renderer will handle the source directly
255
- const isExternalRenderer = ['youtube', 'vimeo', 'soundcloud', 'hls', 'dash'].includes(mediaType);
256
-
257
- if (!isExternalRenderer) {
258
- // Add source for HTML5 media
259
- const source = document.createElement('source');
260
- source.src = track.src || '';
261
- if (track.type) {
262
- source.type = track.type;
263
- }
264
- mediaElement.appendChild(source);
265
-
266
- // Add tracks (captions, chapters, etc.)
267
- if (track.tracks && track.tracks.length > 0) {
268
- track.tracks.forEach((trackConfig: PlaylistTextTrack) => {
269
- const trackEl = document.createElement('track');
270
- trackEl.src = trackConfig.src || '';
271
- trackEl.kind = trackConfig.kind || 'captions';
272
- trackEl.srclang = trackConfig.srclang || 'en';
273
- trackEl.label = trackConfig.label || trackConfig.srclang || '';
274
- if (trackConfig.default) {
275
- trackEl.default = true;
276
- }
277
- mediaElement.appendChild(trackEl);
278
- });
279
- }
280
- }
281
-
282
- this.hostElement.appendChild(mediaElement);
283
-
284
- // Create new player with the media element
285
- // Pass the source for external renderers via options
286
- const playerOptions = {
287
- mediaType: elementType,
288
- poster: track.poster,
289
- audioDescriptionSrc: track.audioDescriptionSrc || null,
290
- audioDescriptionDuration: track.audioDescriptionDuration || null,
291
- signLanguageSrc: track.signLanguageSrc || null
292
- };
293
- // Merge back preserved options (so deferLoad/preload/etc remain active)
294
- Object.assign(playerOptions, preservedPlayerOptions);
295
-
296
- this.player = new this.PlayerClass(mediaElement, playerOptions);
297
-
298
- // Re-register playlist manager
299
- this.player.playlistManager = this;
300
-
301
- // Wait for player to be ready
302
- await new Promise<void>(resolve => {
303
- this.player.on('ready', () => resolve());
304
- });
305
-
306
- // Re-attach event listeners
307
- this.player.on('ended', this.handleTrackEnd);
308
- this.player.on('error', this.handleTrackError);
309
-
310
- // Re-attach all playlist UI elements to the new player's container
311
- if (this.player.container) {
312
- // Track artwork goes before video wrapper
313
- if (this.trackArtworkElement) {
314
- const videoWrapper = this.player.container.querySelector('.vidply-video-wrapper');
315
- if (videoWrapper) {
316
- this.player.container.insertBefore(this.trackArtworkElement, videoWrapper);
317
- } else {
318
- this.player.container.appendChild(this.trackArtworkElement);
319
- }
320
- }
321
- // Track info
322
- if (this.trackInfoElement) {
323
- this.player.container.appendChild(this.trackInfoElement);
324
- }
325
- // Navigation feedback (screen reader only)
326
- if (this.navigationFeedback) {
327
- this.player.container.appendChild(this.navigationFeedback);
328
- }
329
- // Playlist panel
330
- if (this.playlistPanel) {
331
- this.player.container.appendChild(this.playlistPanel);
332
- }
333
- }
334
-
335
- // Update container reference
336
- this.container = this.player.container;
337
-
338
- // Update controls (adds playlist prev/next buttons)
339
- this.updatePlayerControls();
340
-
341
- // Restore tracks data (we kept it during recreation)
342
- this.tracks = savedTracks;
343
- this.currentIndex = savedIndex;
344
-
345
- // Update playlist UI to reflect current state
346
- this.updatePlaylistUI();
347
-
348
- // Restore playlist panel visibility
349
- this.isPanelVisible = wasVisible;
350
- if (this.playlistPanel) {
351
- this.playlistPanel.style.display = wasVisible ? '' : 'none';
352
- }
353
-
354
- // For external renderers, load the track via player.load()
355
- // For HTML5, the source is already set on the element
356
- if (isExternalRenderer) {
357
- this.player.load({
358
- src: track.src ?? '',
359
- type: track.type,
360
- poster: track.poster,
361
- tracks: track.tracks || [],
362
- audioDescriptionSrc: track.audioDescriptionSrc || null,
363
- signLanguageSrc: track.signLanguageSrc || null
364
- });
365
- } else {
366
- this.player.load({
367
- src: track.src ?? '',
368
- type: track.type,
369
- poster: track.poster,
370
- tracks: track.tracks || [],
371
- audioDescriptionSrc: track.audioDescriptionSrc || null,
372
- signLanguageSrc: track.signLanguageSrc || null
373
- });
374
- }
375
-
376
- // Auto-play if requested
377
- if (autoPlay) {
378
- setTimeout(() => {
379
- this.player.play();
380
- }, 100);
381
- }
382
-
383
- return true;
384
- }
385
-
386
- init() {
387
- // Listen for track end
388
- this.player.on('ended', this.handleTrackEnd);
389
- this.player.on('error', this.handleTrackError);
390
-
391
- // Listen for playback state changes to show/hide playlist in fullscreen.
392
- // Handlers are pre-bound in the constructor so destroy() can detach them.
393
- this.player.on('play', this.handlePlaybackStateChange);
394
- this.player.on('pause', this.handlePlaybackStateChange);
395
- this.player.on('ended', this.handlePlaybackStateChange);
396
- // Use fullscreenchange event which is what the player actually emits
397
- this.player.on('fullscreenchange', this.handleFullscreenChange);
398
-
399
- // Listen for audio description state changes to update duration displays
400
- this.player.on('audiodescriptionenabled', this.handleAudioDescriptionChange);
401
- this.player.on('audiodescriptiondisabled', this.handleAudioDescriptionChange);
402
-
403
- // Create UI if needed
404
- if (this.options.showPanel) {
405
- this.createUI();
406
- }
407
-
408
- // Check for data-playlist attribute on player container (only if tracks weren't provided in options)
409
- if (this.tracks.length === 0 && this.initialTracks.length === 0) {
410
- this.loadPlaylistFromAttribute();
411
- }
412
- }
413
-
414
- /**
415
- * Load playlist from data-playlist attribute if present
416
- */
417
- loadPlaylistFromAttribute() {
418
- // Check the original wrapper element for data-playlist
419
- // Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
420
- // So we need to go up 3 levels
421
- if (!this.player.element || !this.player.element.parentElement) {
422
- return;
423
- }
424
-
425
- const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
426
- const playerContainer = videoWrapper.parentElement; // .vidply-player
427
- const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
428
-
429
- if (!originalElement) {
430
- return;
431
- }
432
-
433
- // Load playlist options from data attributes
434
- this.loadOptionsFromAttributes(originalElement);
435
-
436
- const playlistData = originalElement.getAttribute('data-playlist');
437
- if (!playlistData) {
438
- return;
439
- }
440
-
441
- try {
442
- const tracks = JSON.parse(playlistData);
443
- if (Array.isArray(tracks) && tracks.length > 0) {
444
- this.loadPlaylist(tracks);
445
- } else {
446
- console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
447
- }
448
- } catch (error) {
449
- console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
450
- }
451
- }
452
-
453
- /**
454
- * Load playlist options from data attributes
455
- * @param {HTMLElement} element - Element to read attributes from
456
- */
457
- loadOptionsFromAttributes(element: HTMLElement) {
458
- // data-playlist-auto-advance
459
- const autoAdvance = element.getAttribute('data-playlist-auto-advance');
460
- if (autoAdvance !== null) {
461
- this.options.autoAdvance = autoAdvance === 'true';
462
- }
463
-
464
- // data-playlist-auto-play-first
465
- const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
466
- if (autoPlayFirst !== null) {
467
- this.options.autoPlayFirst = autoPlayFirst === 'true';
468
- }
469
-
470
- // data-playlist-loop
471
- const loop = element.getAttribute('data-playlist-loop');
472
- if (loop !== null) {
473
- this.options.loop = loop === 'true';
474
- }
475
-
476
- // data-playlist-show-panel
477
- const showPanel = element.getAttribute('data-playlist-show-panel');
478
- if (showPanel !== null) {
479
- this.options.showPanel = showPanel === 'true';
480
- }
481
- }
482
-
483
- /**
484
- * Update player controls to add playlist navigation buttons
485
- */
486
- updatePlayerControls() {
487
- if (!this.player.controlBar) return;
488
-
489
- const controlBar = this.player.controlBar;
490
-
491
- // Clear existing controls content (except the element itself)
492
- controlBar.element.innerHTML = '';
493
-
494
- // Recreate controls with playlist buttons now available
495
- controlBar.createControls();
496
-
497
- // Reattach events for the new controls
498
- controlBar.attachEvents();
499
- controlBar.setupAutoHide();
500
- }
501
-
502
- /**
503
- * Load a playlist
504
- * @param {Array} tracks - Array of track objects
505
- */
506
- loadPlaylist(tracks: PlaylistTrack[]) {
507
- this.tracks = tracks;
508
- this.currentIndex = -1;
509
-
510
- // Add playlist class to container
511
- if (this.container) {
512
- this.container.classList.add('vidply-has-playlist');
513
- }
514
-
515
- // Update UI
516
- if (this.playlistPanel) {
517
- this.renderPlaylist();
518
- }
519
-
520
- // Auto-play first track (if enabled)
521
- if (tracks.length > 0) {
522
- if (this.options.autoPlayFirst) {
523
- this.play(0);
524
- } else {
525
- // Behave like a single video: load the first track (metadata/manifest)
526
- // but do not start playback.
527
- void this.loadTrack(0).catch(() => {
528
- // ignore
529
- });
530
- }
531
- }
532
-
533
- // Update visibility based on current state
534
- this.updatePlaylistVisibilityInFullscreen();
535
- }
536
-
537
- /**
538
- * Load a track without playing
539
- * This is the playlist equivalent of a "single video initialized but not started yet":
540
- * it updates UI selection and loads the media into the player so metadata/manifests
541
- * and feature managers can be ready, but it does not start playback.
542
- * @param {number} index - Track index
543
- */
544
- async loadTrack(index: number) {
545
- if (index < 0 || index >= this.tracks.length) {
546
- console.warn('VidPly Playlist: Invalid track index', index);
547
- return;
548
- }
549
-
550
- const track = this.tracks[index];
551
- if (!track) return;
552
-
553
- // Always update UI immediately (poster, buttons, duration, etc.).
554
- // Note: this is UI-only; actual media loading is performed by player.load() below.
555
- this.selectTrack(index);
556
-
557
- // Set guard flag to prevent cascade of next() calls during track change
558
- this.isChangingTrack = true;
559
-
560
- // Check if we should recreate the player for this track type
561
- if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
562
- const currentMediaType = this.player ?
563
- (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
564
- const newMediaType = this.getTrackMediaType(track);
565
- const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
566
-
567
- // Recreate if element type is different
568
- if (currentMediaType !== newElementType) {
569
- await this.recreatePlayerForTrack(track, false);
570
- // Re-apply selection to the newly created player (poster/tracks/buttons)
571
- this.selectTrack(index);
572
-
573
- // Emit event
574
- this.player.emit('playlisttrackchange', {
575
- index: index,
576
- item: track,
577
- total: this.tracks.length
578
- });
579
-
580
- // Clear guard flag
581
- setTimeout(() => {
582
- this.isChangingTrack = false;
583
- }, 150);
584
- return;
585
- }
586
- }
587
-
588
- const loadPromise = this.player.load({
589
- src: track.src ?? '',
590
- type: track.type,
591
- poster: track.poster,
592
- tracks: track.tracks || [],
593
- audioDescriptionSrc: track.audioDescriptionSrc || null,
594
- signLanguageSrc: track.signLanguageSrc || null,
595
- signLanguageSources: track.signLanguageSources || {}
596
- });
597
-
598
- // For playlist UX parity with single videos: fetch metadata/manifest now,
599
- // but do not start playback.
600
- if (this.player?.options?.deferLoad && typeof this.player.ensureLoaded === 'function') {
601
- Promise.resolve(loadPromise)
602
- .then(() => this.player?.ensureLoaded?.())
603
- .catch(() => {
604
- // ignore
605
- });
606
- }
607
-
608
- // Emit event
609
- this.player.emit('playlisttrackchange', {
610
- index: index,
611
- item: track,
612
- total: this.tracks.length
613
- });
614
-
615
- // Clear guard flag after a short delay to ensure track is loaded
616
- setTimeout(() => {
617
- this.isChangingTrack = false;
618
- }, 150);
619
- }
620
-
621
- /**
622
- * Select a track (UI/selection only; does NOT set the media src / does NOT initialize renderer)
623
- *
624
- * In "B always" playlist mode, you typically want `loadTrack()` on selection so the
625
- * selected item behaves like a single video (metadata/manifest loaded, features ready)
626
- * without auto-playing.
627
- * @param {number} index - Track index
628
- */
629
- selectTrack(index: number) {
630
- if (index < 0 || index >= this.tracks.length) {
631
- console.warn('VidPly Playlist: Invalid track index', index);
632
- return;
633
- }
634
-
635
- const track = this.tracks[index];
636
- if (!track) return;
637
- this.currentIndex = index;
638
-
639
- // Apply per-track metadata without touching the media source.
640
- // This ensures poster + feature buttons (chapters/captions/transcript/sign-language)
641
- // can be updated instantly even before any media network activity happens.
642
- try {
643
- // Poster for video element — validate the URL from the manifest
644
- // before we hand it to `<video>.poster`. Without the allow-list an
645
- // attacker-controlled `javascript:` or `data:text/html` value could
646
- // reach the attribute.
647
- if (this.player?.element?.tagName === 'VIDEO') {
648
- if (track.poster) {
649
- const resolved: string = typeof this.player.resolvePosterPath === 'function'
650
- ? this.player.resolvePosterPath(track.poster)
651
- : track.poster;
652
- const posterUrl = sanitizePosterUrl(resolved);
653
- if (posterUrl) {
654
- (this.player.element as HTMLVideoElement).poster = posterUrl;
655
- (this.player as Player & { applyPosterAspectRatio?: (url: string) => void }).applyPosterAspectRatio?.(posterUrl);
656
- } else {
657
- this.player.element.removeAttribute('poster');
658
- }
659
- } else {
660
- this.player.element.removeAttribute('poster');
661
- }
662
- }
663
-
664
- // Update sign language / audio description sources (used for button visibility)
665
- this.player.audioDescriptionSrc = track.audioDescriptionSrc || null;
666
- this.player.signLanguageSrc = track.signLanguageSrc || null;
667
- this.player.signLanguageSources = track.signLanguageSources || {};
668
-
669
- // Fill duration early for UI (progress/time display) without loading media
670
- if (track.duration && Number(track.duration) > 0) {
671
- this.player.state.duration = Number(track.duration);
672
- }
673
- // Also sync feature managers (they keep their own copy of sources)
674
- if (this.player.audioDescriptionManager) {
675
- this.player.audioDescriptionManager.src = track.audioDescriptionSrc || null;
676
- // Remember original (non-described) source for switching back later
677
- this.player.audioDescriptionManager.originalSource = track.src || this.player.originalSrc || null;
678
- }
679
- if (this.player.signLanguageManager) {
680
- this.player.signLanguageManager.src = track.signLanguageSrc || null;
681
- this.player.signLanguageManager.sources = track.signLanguageSources || {};
682
- this.player.signLanguageManager.currentLanguage = null;
683
- }
684
-
685
- // For audio description switching, remember original source even before first play
686
- if (track.src && !this.player.originalSrc) {
687
- this.player.originalSrc = track.src;
688
- }
689
-
690
- // Replace <track> elements so captions/chapters/transcript can be detected/loaded
691
- const existing: HTMLElement[] = Array.from(this.player.element.querySelectorAll('track'));
692
- existing.forEach(t => t.remove());
693
-
694
- if (Array.isArray(track.tracks)) {
695
- track.tracks.forEach((tc: PlaylistTextTrack) => {
696
- if (!tc?.src) return;
697
- const el = document.createElement('track');
698
- el.src = tc.src;
699
- el.kind = tc.kind || 'captions';
700
- el.srclang = tc.srclang || 'en';
701
- el.label = tc.label || tc.srclang || 'Track';
702
- if (tc.default) el.default = true;
703
- if (tc.describedSrc) {
704
- el.setAttribute('data-desc-src', tc.describedSrc);
705
- }
706
- this.player.element.appendChild(el);
707
- });
708
- }
709
-
710
- if (typeof this.player.invalidateTrackCache === 'function') {
711
- this.player.invalidateTrackCache();
712
- }
713
-
714
- // Re-scan described-track metadata for AudioDescriptionManager
715
- if (this.player.audioDescriptionManager && typeof this.player.audioDescriptionManager.initFromSourceElements === 'function') {
716
- try {
717
- this.player.audioDescriptionManager.captionTracks = [];
718
- this.player.audioDescriptionManager.initFromSourceElements(this.player.sourceElements, this.player.trackElements);
719
- } catch {
720
- // ignore
721
- }
722
- }
723
-
724
- // Refresh caption/transcript managers so menus reflect newly injected <track> elements
725
- // (important when we defer MP4/MP3 loading but still want VTT-based UI to work).
726
- if (this.player.captionManager && typeof this.player.captionManager.loadTracks === 'function') {
727
- try {
728
- this.player.captionManager.tracks = [];
729
- this.player.captionManager.currentTrack = null;
730
- this.player.captionManager.loadTracks();
731
- } catch {
732
- // ignore
733
- }
734
- }
735
-
736
- // TranscriptManager reads from TextTracks too; it will be correct after media starts.
737
- // For now, we just ensure control bar is rebuilt so the button is present.
738
-
739
- // Rebuild controls so feature buttons appear immediately
740
- if (typeof this.player.updateControlBar === 'function') {
741
- this.player.updateControlBar();
742
- }
743
- } catch {
744
- // ignore preview errors; selection should still work
745
- }
746
-
747
- this.updateTrackInfo(track);
748
- this.updatePlaylistUI();
749
-
750
- this.player.emit('playlisttrackselect', {
751
- index,
752
- item: track,
753
- total: this.tracks.length
754
- });
755
- }
756
-
757
- /**
758
- * Play a specific track
759
- * @param {number} index - Track index
760
- * @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
761
- */
762
- async play(index: number, _userInitiated = false) {
763
- if (index < 0 || index >= this.tracks.length) {
764
- console.warn('VidPly Playlist: Invalid track index', index);
765
- return;
766
- }
767
-
768
- const track = this.tracks[index];
769
- if (!track) return;
770
-
771
- // Set guard flag to prevent cascade of next() calls during track change
772
- this.isChangingTrack = true;
773
-
774
- // Update current index
775
- this.currentIndex = index;
776
-
777
- // Check if we should recreate the player for this track type
778
- if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
779
- const currentMediaType = this.player ?
780
- (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
781
- const newMediaType = this.getTrackMediaType(track);
782
- const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
783
-
784
- // Recreate if element type is different
785
- if (currentMediaType !== newElementType) {
786
- await this.recreatePlayerForTrack(track, true); // true = autoPlay
787
- // Update UI after recreation
788
- this.updateTrackInfo(track);
789
- this.updatePlaylistUI();
790
-
791
- // Emit event
792
- this.player.emit('playlisttrackchange', {
793
- index: index,
794
- item: track,
795
- total: this.tracks.length
796
- });
797
-
798
- // Clear guard flag
799
- setTimeout(() => {
800
- this.isChangingTrack = false;
801
- }, 150);
802
- return;
803
- }
804
- }
805
-
806
- // Load track into player (normal path)
807
- // If audio description was toggled before the first play, load the described source directly.
808
- let srcToLoad = track.src;
809
- if (this.player?.audioDescriptionManager?.desiredState && track.audioDescriptionSrc) {
810
- this.player.originalSrc = track.src ?? null;
811
- this.player.audioDescriptionManager.originalSource = track.src ?? null;
812
- this.player.audioDescriptionManager.src = track.audioDescriptionSrc;
813
- srcToLoad = track.audioDescriptionSrc;
814
- }
815
-
816
- this.player.load({
817
- src: srcToLoad ?? '',
818
- type: track.type,
819
- poster: track.poster,
820
- tracks: track.tracks || [],
821
- audioDescriptionSrc: track.audioDescriptionSrc || null,
822
- signLanguageSrc: track.signLanguageSrc || null,
823
- signLanguageSources: track.signLanguageSources || {}
824
- });
825
-
826
- // Update UI
827
- this.updateTrackInfo(track);
828
- this.updatePlaylistUI();
829
-
830
- // Emit event
831
- this.player.emit('playlisttrackchange', {
832
- index: index,
833
- item: track,
834
- total: this.tracks.length
835
- });
836
-
837
- // Auto-play and clear guard flag after playback starts
838
- setTimeout(() => {
839
- this.player.play();
840
- // Clear guard flag after a short delay to ensure track has started
841
- setTimeout(() => {
842
- this.isChangingTrack = false;
843
- }, 50);
844
- }, 100);
845
- }
846
-
847
- /**
848
- * Play next track
849
- */
850
- next() {
851
- let nextIndex = this.currentIndex + 1;
852
-
853
- if (nextIndex >= this.tracks.length) {
854
- if (this.options.loop) {
855
- nextIndex = 0;
856
- } else {
857
- return;
858
- }
859
- }
860
-
861
- this.play(nextIndex);
862
- }
863
-
864
- /**
865
- * Play previous track
866
- */
867
- previous() {
868
- let prevIndex = this.currentIndex - 1;
869
-
870
- if (prevIndex < 0) {
871
- if (this.options.loop) {
872
- prevIndex = this.tracks.length - 1;
873
- } else {
874
- return;
875
- }
876
- }
877
-
878
- this.play(prevIndex);
879
- }
880
-
881
- /**
882
- * Handle track end
883
- */
884
- handleTrackEnd() {
885
- // Don't auto-advance if we're already in the process of changing tracks
886
- // This prevents a cascade of next() calls when loading a new track triggers an 'ended' event
887
- if (this.isChangingTrack) {
888
- return;
889
- }
890
-
891
- if (this.options.autoAdvance) {
892
- this.next();
893
- }
894
- }
895
-
896
- /**
897
- * Check if a source URL requires an external renderer
898
- * @param {string} src - Source URL
899
- * @returns {boolean}
900
- */
901
- isExternalRendererUrl(src: string | null | undefined) {
902
- if (!src) return false;
903
- return src.includes('youtube.com') ||
904
- src.includes('youtu.be') ||
905
- src.includes('vimeo.com') ||
906
- src.includes('soundcloud.com') ||
907
- src.includes('api.soundcloud.com') ||
908
- src.includes('.m3u8') ||
909
- src.includes('.mpd');
910
- }
911
-
912
- /**
913
- * Handle track error
914
- */
915
- handleTrackError(e: unknown) {
916
- // Don't auto-advance for external renderer tracks
917
- // External renderers (YouTube, Vimeo, SoundCloud, HLS, DASH) may trigger HTML5 errors
918
- // that should be ignored since the external renderer handles playback
919
- const currentTrack = this.getCurrentTrack();
920
- if (currentTrack && currentTrack.src && this.isExternalRendererUrl(currentTrack.src)) {
921
- // Silently ignore errors for external renderer tracks
922
- return;
923
- }
924
-
925
- // Don't auto-advance if we're in the process of changing tracks
926
- // This prevents a cascade of next() calls when switching between renderer types
927
- if (this.isChangingTrack) {
928
- return;
929
- }
930
-
931
- console.error('VidPly Playlist: Track error', e);
932
-
933
- // Try next track
934
- if (this.options.autoAdvance) {
935
- setTimeout(() => {
936
- this.next();
937
- }, 1000);
938
- }
939
- }
940
-
941
- /**
942
- * Handle playback state changes (for fullscreen playlist visibility)
943
- */
944
- handlePlaybackStateChange() {
945
- this.updatePlaylistVisibilityInFullscreen();
946
- }
947
-
948
- /**
949
- * Handle fullscreen state changes
950
- */
951
- handleFullscreenChange() {
952
- // Use a small delay to ensure fullscreen state is fully applied
953
- setTimeout(() => {
954
- this.updatePlaylistVisibilityInFullscreen();
955
- }, 50);
956
- }
957
-
958
- /**
959
- * Handle audio description state changes
960
- * Updates duration displays to show audio-described version duration when AD is enabled
961
- */
962
- handleAudioDescriptionChange() {
963
- const currentTrack = this.getCurrentTrack();
964
- if (!currentTrack) return;
965
-
966
- // Update the track info display with the appropriate duration
967
- this.updateTrackInfo(currentTrack);
968
-
969
- // Update the playlist UI to reflect duration changes (aria-labels)
970
- this.updatePlaylistUI();
971
-
972
- // Update visual duration elements in playlist panel
973
- this.updatePlaylistDurations();
974
- }
975
-
976
- /**
977
- * Update the visual duration displays in the playlist panel
978
- * Called when audio description state changes
979
- */
980
- updatePlaylistDurations() {
981
- if (!this.playlistPanel) return;
982
-
983
- const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
984
-
985
- items.forEach((item: Element, index: number) => {
986
- const track = this.tracks[index];
987
- if (!track) return;
988
-
989
- const effectiveDuration = this.getEffectiveDuration(track);
990
- const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
991
-
992
- // Update duration badge on thumbnail (if exists)
993
- const durationBadge = item.querySelector('.vidply-playlist-duration-badge');
994
- if (durationBadge) {
995
- durationBadge.textContent = trackDuration;
996
- }
997
-
998
- // Update inline duration (if exists)
999
- const inlineDuration = item.querySelector('.vidply-playlist-item-duration');
1000
- if (inlineDuration) {
1001
- inlineDuration.textContent = trackDuration;
1002
- }
1003
- });
1004
- }
1005
-
1006
- /**
1007
- * Get the effective duration for a track based on audio description state
1008
- * @param {Object} track - Track object
1009
- * @returns {number|null} - Duration in seconds or null if not available
1010
- */
1011
- getEffectiveDuration(track: PlaylistTrack) {
1012
- if (!track) return null;
1013
-
1014
- const isAudioDescriptionEnabled = this.player.state.audioDescriptionEnabled;
1015
-
1016
- // If audio description is enabled and track has audioDescriptionDuration, use it
1017
- if (isAudioDescriptionEnabled && track.audioDescriptionDuration) {
1018
- return track.audioDescriptionDuration;
1019
- }
1020
-
1021
- // Otherwise use regular duration
1022
- return track.duration || null;
1023
- }
1024
-
1025
- /**
1026
- * Update playlist visibility based on fullscreen and playback state
1027
- * In fullscreen: show when paused/not started, hide when playing
1028
- * Outside fullscreen: respect original panel visibility setting
1029
- */
1030
- updatePlaylistVisibilityInFullscreen() {
1031
- const playlistPanel = this.playlistPanel;
1032
- if (!playlistPanel || !this.tracks.length) return;
1033
-
1034
- const isFullscreen = this.player.state.fullscreen;
1035
- const isPlaying = this.player.state.playing;
1036
-
1037
- if (isFullscreen) {
1038
- // In fullscreen: show only when not playing (paused or not started)
1039
- // Check playing state explicitly since paused might not be set initially
1040
- if (!isPlaying) {
1041
- playlistPanel.classList.add('vidply-playlist-fullscreen-visible');
1042
- playlistPanel.style.display = 'block';
1043
- } else {
1044
- playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
1045
- // Add a smooth fade out with delay to match CSS transition
1046
- setTimeout(() => {
1047
- // Double-check state hasn't changed before hiding
1048
- if (this.player.state.playing && this.player.state.fullscreen) {
1049
- playlistPanel.style.display = 'none';
1050
- }
1051
- }, 300); // Match CSS transition duration
1052
- }
1053
- } else {
1054
- // Outside fullscreen: restore original behavior
1055
- playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
1056
- if (this.isPanelVisible && this.tracks.length > 0) {
1057
- playlistPanel.style.display = 'block';
1058
- } else {
1059
- playlistPanel.style.display = 'none';
1060
- }
1061
- }
1062
- }
1063
-
1064
- /**
1065
- * Create playlist UI
1066
- */
1067
- createUI() {
1068
- // Find player container
1069
- this.container = this.player.container;
1070
-
1071
- if (!this.container) {
1072
- console.warn('VidPly Playlist: No container found');
1073
- return;
1074
- }
1075
-
1076
- // Track artwork element (shows album art/poster for audio tracks).
1077
- // Important: in mixed playlists the player may start as <video> and later recreate to <audio>,
1078
- // so we create this lazily in `updateTrackArtwork()` when we actually have an audio element.
1079
-
1080
- // Create track info element (shows current track)
1081
- this.trackInfoElement = DOMUtils.createElement('div', {
1082
- className: 'vidply-track-info',
1083
- attributes: {
1084
- role: 'status'
1085
- }
1086
- });
1087
- this.trackInfoElement.style.display = 'none';
1088
-
1089
- this.container.appendChild(this.trackInfoElement);
1090
-
1091
- // Create navigation feedback live region
1092
- this.navigationFeedback = DOMUtils.createElement('div', {
1093
- className: 'vidply-sr-only',
1094
- attributes: {
1095
- role: 'status',
1096
- 'aria-live': 'polite',
1097
- 'aria-atomic': 'true'
1098
- }
1099
- });
1100
- this.container.appendChild(this.navigationFeedback);
1101
-
1102
- // Create playlist panel with proper landmark
1103
- this.playlistPanel = DOMUtils.createElement('div', {
1104
- className: 'vidply-playlist-panel',
1105
- attributes: {
1106
- id: `${this.uniqueId}-panel`,
1107
- role: 'region',
1108
- 'aria-label': i18n.t('playlist.title'),
1109
- 'aria-labelledby': `${this.uniqueId}-heading`
1110
- }
1111
- });
1112
- this.playlistPanel.style.display = this.isPanelVisible ? 'none' : 'none'; // Will be shown when playlist is loaded
1113
-
1114
- this.container.appendChild(this.playlistPanel);
1115
- }
1116
-
1117
- /**
1118
- * Update track info display
1119
- */
1120
- updateTrackInfo(track: PlaylistTrack) {
1121
- if (!this.trackInfoElement) return;
1122
-
1123
- const trackNumber = this.currentIndex + 1;
1124
- const totalTracks = this.tracks.length;
1125
- const trackTitle = track.title || i18n.t('playlist.untitled');
1126
- const trackArtist = track.artist || '';
1127
-
1128
- // Use effective duration (audio description duration when AD is enabled)
1129
- const effectiveDuration = this.getEffectiveDuration(track);
1130
- const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
1131
- const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
1132
-
1133
- // Screen reader announcement - include duration if available
1134
- const artistPart = trackArtist ? i18n.t('playlist.by') + trackArtist : '';
1135
- const durationPart = trackDurationReadable ? `. ${trackDurationReadable}` : '';
1136
- const announcement = i18n.t('playlist.nowPlaying', {
1137
- current: trackNumber,
1138
- total: totalTracks,
1139
- title: trackTitle,
1140
- artist: artistPart
1141
- }) + durationPart;
1142
-
1143
- const trackOfText = i18n.t('playlist.trackOf', {
1144
- current: trackNumber,
1145
- total: totalTracks
1146
- });
1147
-
1148
- // Build duration HTML if available
1149
- const durationHtml = trackDuration
1150
- ? `<span class="vidply-track-duration" aria-hidden="true">${DOMUtils.escapeHTML(trackDuration)}</span>`
1151
- : '';
1152
-
1153
- // Get description if available
1154
- const trackDescription = track.description || '';
1155
-
1156
- this.trackInfoElement.innerHTML = `
1157
- <span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
1158
- <div class="vidply-track-header" aria-hidden="true">
1159
- <span class="vidply-track-number">${DOMUtils.escapeHTML(trackOfText)}</span>
1160
- ${durationHtml}
1161
- </div>
1162
- <div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
1163
- ${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
1164
- ${trackDescription ? `<div class="vidply-track-description" aria-hidden="true">${DOMUtils.escapeHTML(trackDescription)}</div>` : ''}
1165
- `;
1166
-
1167
- this.trackInfoElement.style.display = 'block';
1168
-
1169
- // Update track artwork if available (for audio playlists)
1170
- this.updateTrackArtwork(track);
1171
- }
1172
-
1173
- /**
1174
- * Update track artwork display (for audio playlists)
1175
- */
1176
- updateTrackArtwork(track: PlaylistTrack) {
1177
- // Only show artwork for audio players.
1178
- // In mixed playlists we may recreate from <video> -> <audio> later, so ensure the element exists lazily.
1179
- if (this.player?.element?.tagName !== 'AUDIO') {
1180
- if (this.trackArtworkElement) {
1181
- this.trackArtworkElement.style.display = 'none';
1182
- }
1183
- return;
1184
- }
1185
-
1186
- // Lazily create artwork element once we have an audio element/container.
1187
- if (!this.trackArtworkElement && this.container) {
1188
- this.trackArtworkElement = DOMUtils.createElement('div', {
1189
- className: 'vidply-track-artwork',
1190
- attributes: {
1191
- 'aria-hidden': 'true'
1192
- }
1193
- });
1194
- this.trackArtworkElement.style.display = 'none';
1195
-
1196
- // Insert before video wrapper (if present) for consistent layout.
1197
- const videoWrapper = this.container.querySelector('.vidply-video-wrapper');
1198
- if (videoWrapper) {
1199
- this.container.insertBefore(this.trackArtworkElement, videoWrapper);
1200
- } else {
1201
- this.container.appendChild(this.trackArtworkElement);
1202
- }
1203
- }
1204
-
1205
- if (!this.trackArtworkElement) return;
1206
-
1207
- // A track manifest is attacker-influenced data a value like
1208
- // `x); background: url(evil.svg` must not be allowed to break out
1209
- // of the declaration. `toCssBackgroundImage` returns null when the
1210
- // URL fails validation, so we leave the element hidden instead of
1211
- // assigning a dangerous value.
1212
- const safeBackground = track.poster ? toCssBackgroundImage(track.poster) : null;
1213
- if (safeBackground) {
1214
- this.trackArtworkElement.style.backgroundImage = safeBackground;
1215
- this.trackArtworkElement.style.display = 'block';
1216
- } else {
1217
- this.trackArtworkElement.style.backgroundImage = '';
1218
- this.trackArtworkElement.style.display = 'none';
1219
- }
1220
- }
1221
-
1222
- /**
1223
- * Render playlist
1224
- */
1225
- renderPlaylist() {
1226
- if (!this.playlistPanel) return;
1227
-
1228
- // Clear existing
1229
- this.playlistPanel.innerHTML = '';
1230
-
1231
- // Create header
1232
- const header = DOMUtils.createElement('h2', {
1233
- className: 'vidply-playlist-header',
1234
- attributes: {
1235
- id: `${this.uniqueId}-heading`
1236
- }
1237
- });
1238
- header.textContent = `${i18n.t('playlist.title')} (${this.tracks.length})`;
1239
- this.playlistPanel.appendChild(header);
1240
-
1241
- // Add keyboard instructions (visually hidden)
1242
- const instructions = DOMUtils.createElement('div', {
1243
- className: 'vidply-sr-only',
1244
- attributes: {
1245
- id: `${this.uniqueId}-keyboard-instructions`
1246
- }
1247
- });
1248
- instructions.textContent = i18n.t('playlist.keyboardInstructions');
1249
- this.playlistPanel.appendChild(instructions);
1250
-
1251
- // Create list (proper ul element)
1252
- // Note: role="listbox" requires option children. We use role="none" on <li> elements
1253
- // so the buttons with role="option" become the semantic children of the listbox.
1254
- const list = DOMUtils.createElement('ul', {
1255
- className: 'vidply-playlist-list',
1256
- attributes: {
1257
- role: 'listbox',
1258
- 'aria-labelledby': `${this.uniqueId}-heading`,
1259
- 'aria-describedby': `${this.uniqueId}-keyboard-instructions`
1260
- }
1261
- });
1262
-
1263
- this.tracks.forEach((track, index) => {
1264
- const item = this.createPlaylistItem(track, index);
1265
- list.appendChild(item);
1266
- });
1267
-
1268
- this.playlistPanel.appendChild(list);
1269
-
1270
- // Show panel if it should be visible
1271
- if (this.isPanelVisible) {
1272
- this.playlistPanel.style.display = 'block';
1273
- }
1274
- }
1275
-
1276
- /**
1277
- * Create playlist item element
1278
- */
1279
- createPlaylistItem(track: PlaylistTrack, index: number) {
1280
- const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1281
- const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1282
-
1283
- // Use effective duration (audio description duration when AD is enabled)
1284
- const effectiveDuration = this.getEffectiveDuration(track);
1285
- const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
1286
- const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
1287
- const isActive = index === this.currentIndex;
1288
-
1289
- // Build accessible label for screen readers
1290
- // With role="option" and aria-checked, screen reader will announce selection state
1291
- // Position is already announced via aria-posinset/aria-setsize
1292
- // Format: "Title by Artist. 3 minutes, 45 seconds."
1293
- let ariaLabel = `${trackTitle}${trackArtist}`;
1294
- if (trackDurationReadable) {
1295
- ariaLabel += `. ${trackDurationReadable}`;
1296
- }
1297
-
1298
- // Create list item container
1299
- // role="none" removes the <li> from the accessibility tree so the button with
1300
- // role="option" becomes the direct semantic child of the listbox (required by ARIA).
1301
- const item = DOMUtils.createElement('li', {
1302
- className: isActive ? 'vidply-playlist-item vidply-playlist-item-active' : 'vidply-playlist-item',
1303
- attributes: {
1304
- 'data-playlist-index': String(index),
1305
- role: 'none'
1306
- }
1307
- });
1308
-
1309
- // Create button wrapper for interactive content
1310
- const button = DOMUtils.createElement('button', {
1311
- className: 'vidply-playlist-item-button',
1312
- attributes: {
1313
- type: 'button',
1314
- role: 'option',
1315
- tabIndex: index === 0 ? '0' : '-1',
1316
- 'aria-label': ariaLabel,
1317
- 'aria-posinset': String(index + 1),
1318
- 'aria-setsize': String(this.tracks.length),
1319
- 'aria-checked': isActive ? 'true' : 'false'
1320
- }
1321
- });
1322
-
1323
- // Add aria-current if active
1324
- if (isActive) {
1325
- button.setAttribute('aria-current', 'true');
1326
- button.setAttribute('tabIndex', '0'); // Active item should always be tabbable
1327
- }
1328
-
1329
- // Thumbnail container with optional duration badge
1330
- const thumbnailContainer = DOMUtils.createElement('span', {
1331
- className: 'vidply-playlist-thumbnail-container',
1332
- attributes: {
1333
- 'aria-hidden': 'true'
1334
- }
1335
- });
1336
-
1337
- // Thumbnail or icon
1338
- const thumbnail = DOMUtils.createElement('span', {
1339
- className: 'vidply-playlist-thumbnail'
1340
- });
1341
-
1342
- // Same rule as trackArtworkElement below: only set backgroundImage
1343
- // when the manifest URL passes the allow-list so a hostile feed
1344
- // cannot break out of the CSS `url(...)` declaration.
1345
- const safeThumbnail = track.poster ? toCssBackgroundImage(track.poster) : null;
1346
- if (safeThumbnail) {
1347
- thumbnail.style.backgroundImage = safeThumbnail;
1348
- } else {
1349
- const icon = createIconElement('music');
1350
- icon.classList.add('vidply-playlist-thumbnail-icon');
1351
- thumbnail.appendChild(icon);
1352
- }
1353
-
1354
- thumbnailContainer.appendChild(thumbnail);
1355
-
1356
- // Duration badge on thumbnail (like YouTube) - only show if there's a poster
1357
- if (trackDuration && track.poster) {
1358
- const durationBadge = DOMUtils.createElement('span', {
1359
- className: 'vidply-playlist-duration-badge'
1360
- });
1361
- durationBadge.textContent = trackDuration;
1362
- thumbnailContainer.appendChild(durationBadge);
1363
- }
1364
-
1365
- button.appendChild(thumbnailContainer);
1366
-
1367
- // Info section (title, artist, description)
1368
- const info = DOMUtils.createElement('span', {
1369
- className: 'vidply-playlist-item-info',
1370
- attributes: {
1371
- 'aria-hidden': 'true'
1372
- }
1373
- });
1374
-
1375
- // Title row with optional inline duration (for when no thumbnail)
1376
- const titleRow = DOMUtils.createElement('span', {
1377
- className: 'vidply-playlist-item-title-row'
1378
- });
1379
-
1380
- const title = DOMUtils.createElement('span', {
1381
- className: 'vidply-playlist-item-title'
1382
- });
1383
- title.textContent = trackTitle;
1384
- titleRow.appendChild(title);
1385
-
1386
- // Inline duration (shown when no poster/thumbnail)
1387
- if (trackDuration && !track.poster) {
1388
- const inlineDuration = DOMUtils.createElement('span', {
1389
- className: 'vidply-playlist-item-duration'
1390
- });
1391
- inlineDuration.textContent = trackDuration;
1392
- titleRow.appendChild(inlineDuration);
1393
- }
1394
-
1395
- info.appendChild(titleRow);
1396
-
1397
- // Artist
1398
- if (track.artist) {
1399
- const artist = DOMUtils.createElement('span', {
1400
- className: 'vidply-playlist-item-artist'
1401
- });
1402
- artist.textContent = track.artist;
1403
- info.appendChild(artist);
1404
- }
1405
-
1406
- // Description (truncated)
1407
- if (track.description) {
1408
- const description = DOMUtils.createElement('span', {
1409
- className: 'vidply-playlist-item-description'
1410
- });
1411
- description.textContent = track.description;
1412
- info.appendChild(description);
1413
- }
1414
-
1415
- button.appendChild(info);
1416
-
1417
- // Play icon
1418
- const playIcon = createIconElement('play');
1419
- playIcon.classList.add('vidply-playlist-item-icon');
1420
- playIcon.setAttribute('aria-hidden', 'true');
1421
- button.appendChild(playIcon);
1422
-
1423
- // Click handler
1424
- button.addEventListener('click', () => {
1425
- const track = this.tracks[index];
1426
- const isExternalRenderer = this.isExternalRendererUrl(track?.src);
1427
-
1428
- // Exit fullscreen for external renderer tracks (YouTube, Vimeo, SoundCloud)
1429
- // These have their own native controls that work better outside vidply's fullscreen
1430
- if (isExternalRenderer && this.player.state.fullscreen) {
1431
- this.player.exitFullscreen();
1432
- // Small delay to let fullscreen exit before loading
1433
- setTimeout(() => {
1434
- this.play(index, true);
1435
- }, 100);
1436
- } else {
1437
- this.play(index, true); // User-initiated
1438
- }
1439
- });
1440
-
1441
- // Keyboard handler
1442
- button.addEventListener('keydown', (e) => {
1443
- this.handlePlaylistItemKeydown(e, index);
1444
- });
1445
-
1446
- // Append button to list item
1447
- item.appendChild(button);
1448
-
1449
- return item;
1450
- }
1451
-
1452
- /**
1453
- * Handle keyboard navigation in playlist items
1454
- */
1455
- handlePlaylistItemKeydown(e: KeyboardEvent, index: number) {
1456
- if (!this.playlistPanel) return;
1457
- const buttons: HTMLElement[] = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item-button'));
1458
- let newIndex = -1;
1459
- let announcement = '';
1460
-
1461
- switch(e.key) {
1462
- case 'Enter':
1463
- case ' ':
1464
- e.preventDefault();
1465
- e.stopPropagation();
1466
- {
1467
- const track = this.tracks[index];
1468
- const isExternalRenderer = this.isExternalRendererUrl(track?.src);
1469
-
1470
- // Exit fullscreen for external renderer tracks (YouTube, Vimeo, SoundCloud)
1471
- if (isExternalRenderer && this.player.state.fullscreen) {
1472
- this.player.exitFullscreen();
1473
- setTimeout(() => {
1474
- this.play(index, true);
1475
- }, 100);
1476
- } else {
1477
- this.play(index, true); // User-initiated
1478
- }
1479
- }
1480
- return; // No need to move focus
1481
-
1482
- case 'ArrowDown':
1483
- e.preventDefault();
1484
- e.stopPropagation();
1485
- // Move to next item
1486
- if (index < buttons.length - 1) {
1487
- newIndex = index + 1;
1488
- } else {
1489
- // At the end, announce boundary
1490
- announcement = i18n.t('playlist.endOfPlaylist', { current: buttons.length, total: buttons.length });
1491
- }
1492
- break;
1493
-
1494
- case 'ArrowUp':
1495
- e.preventDefault();
1496
- e.stopPropagation();
1497
- // Move to previous item
1498
- if (index > 0) {
1499
- newIndex = index - 1;
1500
- } else {
1501
- // At the beginning, announce boundary
1502
- announcement = i18n.t('playlist.beginningOfPlaylist', { total: buttons.length });
1503
- }
1504
- break;
1505
-
1506
- case 'PageDown':
1507
- e.preventDefault();
1508
- e.stopPropagation();
1509
- // Move 5 items down (or to end)
1510
- newIndex = Math.min(index + 5, buttons.length - 1);
1511
- if (newIndex === buttons.length - 1 && index !== newIndex) {
1512
- announcement = i18n.t('playlist.jumpedToLastTrack', { current: newIndex + 1, total: buttons.length });
1513
- }
1514
- break;
1515
-
1516
- case 'PageUp':
1517
- e.preventDefault();
1518
- e.stopPropagation();
1519
- // Move 5 items up (or to beginning)
1520
- newIndex = Math.max(index - 5, 0);
1521
- if (newIndex === 0 && index !== newIndex) {
1522
- announcement = i18n.t('playlist.jumpedToFirstTrack', { total: buttons.length });
1523
- }
1524
- break;
1525
-
1526
- case 'Home':
1527
- e.preventDefault();
1528
- e.stopPropagation();
1529
- // Move to first item
1530
- newIndex = 0;
1531
- if (index !== 0) {
1532
- announcement = i18n.t('playlist.firstTrack', { total: buttons.length });
1533
- }
1534
- break;
1535
-
1536
- case 'End':
1537
- e.preventDefault();
1538
- e.stopPropagation();
1539
- // Move to last item
1540
- newIndex = buttons.length - 1;
1541
- if (index !== buttons.length - 1) {
1542
- announcement = i18n.t('playlist.lastTrack', { current: buttons.length, total: buttons.length });
1543
- }
1544
- break;
1545
- }
1546
-
1547
- // Update tab indices for roving tabindex pattern
1548
- if (newIndex !== -1 && newIndex !== index) {
1549
- const currentButton = buttons[index];
1550
- const newButton = buttons[newIndex];
1551
- if (currentButton && newButton) {
1552
- currentButton.setAttribute('tabIndex', '-1');
1553
- newButton.setAttribute('tabIndex', '0');
1554
- newButton.focus({ preventScroll: false });
1555
-
1556
- // Scroll the focused item into view (same behavior as mouse interaction)
1557
- const item = newButton.closest('.vidply-playlist-item');
1558
- if (item) {
1559
- item.scrollIntoView(reducedMotionScrollOptions('nearest'));
1560
- }
1561
- }
1562
- }
1563
-
1564
- // Announce navigation feedback
1565
- if (announcement && this.navigationFeedback) {
1566
- this.navigationFeedback.textContent = announcement;
1567
- // Clear after a short delay to allow for repeated announcements
1568
- setTimeout(() => {
1569
- if (this.navigationFeedback) {
1570
- this.navigationFeedback.textContent = '';
1571
- }
1572
- }, 1000);
1573
- }
1574
- }
1575
-
1576
- /**
1577
- * Update playlist UI (highlight current track)
1578
- */
1579
- updatePlaylistUI() {
1580
- if (!this.playlistPanel) return;
1581
-
1582
- const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
1583
- const buttons = this.playlistPanel.querySelectorAll('.vidply-playlist-item-button');
1584
-
1585
- items.forEach((item: Element, index: number) => {
1586
- const button = buttons[index];
1587
- if (!button) return;
1588
-
1589
- const track = this.tracks[index];
1590
- if (!track) return;
1591
- const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1592
- const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1593
-
1594
- // Use effective duration (audio description duration when AD is enabled)
1595
- const effectiveDuration = this.getEffectiveDuration(track);
1596
- const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
1597
-
1598
- if (index === this.currentIndex) {
1599
- // Update list item styling
1600
- item.classList.add('vidply-playlist-item-active');
1601
-
1602
- // Update button ARIA attributes
1603
- button.setAttribute('aria-current', 'true');
1604
- button.setAttribute('aria-checked', 'true');
1605
- button.setAttribute('tabIndex', '0'); // Active item should be tabbable
1606
-
1607
- // Simplified aria-label - status and actions are announced via ARIA roles
1608
- let ariaLabel = `${trackTitle}${trackArtist}`;
1609
- if (trackDurationReadable) {
1610
- ariaLabel += `. ${trackDurationReadable}`;
1611
- }
1612
- button.setAttribute('aria-label', ariaLabel);
1613
-
1614
- // Scroll into view within playlist panel (uses 'nearest' to minimize page scroll)
1615
- item.scrollIntoView(reducedMotionScrollOptions('nearest'));
1616
- } else {
1617
- // Update list item styling
1618
- item.classList.remove('vidply-playlist-item-active');
1619
-
1620
- // Update button ARIA attributes
1621
- button.removeAttribute('aria-current');
1622
- button.setAttribute('aria-checked', 'false');
1623
- button.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
1624
-
1625
- // Simplified aria-label - status and actions are announced via ARIA roles
1626
- let ariaLabel = `${trackTitle}${trackArtist}`;
1627
- if (trackDurationReadable) {
1628
- ariaLabel += `. ${trackDurationReadable}`;
1629
- }
1630
- button.setAttribute('aria-label', ariaLabel);
1631
- }
1632
- });
1633
- }
1634
-
1635
- /**
1636
- * Get current track
1637
- */
1638
- getCurrentTrack() {
1639
- return this.tracks[this.currentIndex] || null;
1640
- }
1641
-
1642
- /**
1643
- * Get playlist info
1644
- */
1645
- getPlaylistInfo() {
1646
- return {
1647
- currentIndex: this.currentIndex,
1648
- totalTracks: this.tracks.length,
1649
- currentTrack: this.getCurrentTrack(),
1650
- hasNext: this.hasNext(),
1651
- hasPrevious: this.hasPrevious()
1652
- };
1653
- }
1654
-
1655
- /**
1656
- * Check if there is a next track
1657
- */
1658
- hasNext() {
1659
- if (this.options.loop) return true;
1660
- return this.currentIndex < this.tracks.length - 1;
1661
- }
1662
-
1663
- /**
1664
- * Check if there is a previous track
1665
- */
1666
- hasPrevious() {
1667
- if (this.options.loop) return true;
1668
- return this.currentIndex > 0;
1669
- }
1670
-
1671
- /**
1672
- * Add track to playlist
1673
- */
1674
- addTrack(track: PlaylistTrack) {
1675
- this.tracks.push(track);
1676
-
1677
- if (this.playlistPanel) {
1678
- this.renderPlaylist();
1679
- }
1680
- }
1681
-
1682
- /**
1683
- * Remove track from playlist
1684
- */
1685
- removeTrack(index: number) {
1686
- if (index < 0 || index >= this.tracks.length) return;
1687
-
1688
- this.tracks.splice(index, 1);
1689
-
1690
- // Adjust current index if needed
1691
- if (index < this.currentIndex) {
1692
- this.currentIndex--;
1693
- } else if (index === this.currentIndex) {
1694
- // Current track was removed, play next or stop
1695
- if (this.currentIndex >= this.tracks.length) {
1696
- this.currentIndex = this.tracks.length - 1;
1697
- }
1698
-
1699
- if (this.currentIndex >= 0) {
1700
- this.play(this.currentIndex);
1701
- }
1702
- }
1703
-
1704
- if (this.playlistPanel) {
1705
- this.renderPlaylist();
1706
- }
1707
- }
1708
-
1709
- /**
1710
- * Clear playlist
1711
- */
1712
- clear() {
1713
- this.tracks = [];
1714
- this.currentIndex = -1;
1715
-
1716
- if (this.playlistPanel) {
1717
- this.playlistPanel.innerHTML = '';
1718
- this.playlistPanel.style.display = 'none';
1719
- }
1720
-
1721
- if (this.trackInfoElement) {
1722
- this.trackInfoElement.innerHTML = '';
1723
- this.trackInfoElement.style.display = 'none';
1724
- }
1725
-
1726
- if (this.trackArtworkElement) {
1727
- this.trackArtworkElement.style.backgroundImage = '';
1728
- this.trackArtworkElement.style.display = 'none';
1729
- }
1730
- }
1731
-
1732
- /**
1733
- * Toggle playlist panel visibility
1734
- * @param {boolean} show - Optional: force show (true) or hide (false)
1735
- * @returns {boolean} - New visibility state
1736
- */
1737
- togglePanel(show?: boolean) {
1738
- const playlistPanel = this.playlistPanel;
1739
- if (!playlistPanel) return false;
1740
-
1741
- // Determine new state
1742
- const shouldShow = show !== undefined ? show : playlistPanel.style.display === 'none';
1743
-
1744
- if (shouldShow) {
1745
- playlistPanel.style.display = 'block';
1746
- this.isPanelVisible = true;
1747
-
1748
- // Focus first item if playlist has tracks
1749
- if (this.tracks.length > 0) {
1750
- setTimeout(() => {
1751
- const firstItem = playlistPanel.querySelector<HTMLElement>('.vidply-playlist-item[tabindex="0"]');
1752
- if (firstItem) {
1753
- firstItem.focus({ preventScroll: true });
1754
- }
1755
- }, 100);
1756
- }
1757
-
1758
- // Update toggle button state if it exists
1759
- if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1760
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'true');
1761
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'true');
1762
- }
1763
- } else {
1764
- playlistPanel.style.display = 'none';
1765
- this.isPanelVisible = false;
1766
-
1767
- // Update toggle button state if it exists
1768
- if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1769
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'false');
1770
- this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'false');
1771
-
1772
- // Return focus to toggle button
1773
- this.player.controlBar.controls.playlistToggle.focus({ preventScroll: true });
1774
- }
1775
- }
1776
-
1777
- return this.isPanelVisible;
1778
- }
1779
-
1780
- /**
1781
- * Show playlist panel
1782
- */
1783
- showPanel() {
1784
- return this.togglePanel(true);
1785
- }
1786
-
1787
- /**
1788
- * Hide playlist panel
1789
- */
1790
- hidePanel() {
1791
- return this.togglePanel(false);
1792
- }
1793
-
1794
- /**
1795
- * Destroy playlist manager
1796
- */
1797
- destroy() {
1798
- // Remove every listener registered in init(). All handlers are pre-bound
1799
- // in the constructor, so these references match what on() registered.
1800
- this.player.off('ended', this.handleTrackEnd);
1801
- this.player.off('error', this.handleTrackError);
1802
- this.player.off('play', this.handlePlaybackStateChange);
1803
- this.player.off('pause', this.handlePlaybackStateChange);
1804
- this.player.off('ended', this.handlePlaybackStateChange);
1805
- this.player.off('fullscreenchange', this.handleFullscreenChange);
1806
- this.player.off('audiodescriptionenabled', this.handleAudioDescriptionChange);
1807
- this.player.off('audiodescriptiondisabled', this.handleAudioDescriptionChange);
1808
-
1809
- // Remove UI
1810
- if (this.trackArtworkElement) {
1811
- this.trackArtworkElement.remove();
1812
- }
1813
-
1814
- if (this.trackInfoElement) {
1815
- this.trackInfoElement.remove();
1816
- }
1817
-
1818
- if (this.playlistPanel) {
1819
- this.playlistPanel.remove();
1820
- }
1821
-
1822
- // Clear data
1823
- this.clear();
1824
- }
1825
- }
1826
-
1827
- 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
+ import { sanitizePosterUrl, toCssBackgroundImage } from '../utils/UrlSafe.js';
11
+ import { reducedMotionScrollOptions } from '../utils/PerformanceUtils.js';
12
+ import type { Player } from '../core/Player.js';
13
+
14
+ type PlaylistTextTrack = {
15
+ src?: string;
16
+ kind?: string;
17
+ srclang?: string;
18
+ label?: string;
19
+ default?: boolean;
20
+ describedSrc?: string;
21
+ [key: string]: unknown;
22
+ };
23
+
24
+ type PlaylistTrack = {
25
+ src?: string;
26
+ type?: string;
27
+ poster?: string;
28
+ tracks?: PlaylistTextTrack[];
29
+ audioDescriptionSrc?: string | null;
30
+ audioDescriptionDuration?: number | string | null;
31
+ signLanguageSrc?: string | null;
32
+ signLanguageSources?: Record<string, string>;
33
+ duration?: number | string | null;
34
+ title?: string;
35
+ artist?: string;
36
+ description?: string;
37
+ [key: string]: unknown;
38
+ };
39
+
40
+ /**
41
+ * Construction signature for the host Player class. The playlist
42
+ * manager receives this via options so it can recreate players when
43
+ * tracks of incompatible media types (e.g. audio after video) load.
44
+ */
45
+ type PlayerConstructor = new (
46
+ element: string | HTMLElement,
47
+ options?: Record<string, unknown>
48
+ ) => Player;
49
+
50
+ /**
51
+ * Internal options bag for the manager. Extra keys from the caller
52
+ * (we accept `Record<string, unknown>`) are merged in via the
53
+ * `[key: string]: unknown` index so consumers can pass through
54
+ * additional player options without losing typing on the known ones.
55
+ */
56
+ interface PlaylistManagerOptions {
57
+ autoAdvance: boolean;
58
+ autoPlayFirst: boolean;
59
+ loop: boolean;
60
+ showPanel: boolean;
61
+ recreatePlayers: boolean;
62
+ hostElement?: HTMLElement | null;
63
+ PlayerClass?: PlayerConstructor | null;
64
+ tracks?: PlaylistTrack[];
65
+ [key: string]: unknown;
66
+ }
67
+
68
+ // Static counter for unique IDs
69
+ let playlistInstanceCounter = 0;
70
+
71
+ export class PlaylistManager {
72
+ player: Player;
73
+ container: HTMLElement | null;
74
+ currentIndex: number;
75
+ hostElement: HTMLElement | null;
76
+ initialTracks: PlaylistTrack[];
77
+ instanceId: number;
78
+ isChangingTrack: boolean;
79
+ isPanelVisible: boolean;
80
+ navigationFeedback: HTMLElement | null;
81
+ options: PlaylistManagerOptions;
82
+ PlayerClass: PlayerConstructor | null;
83
+ playlistPanel: HTMLElement | null;
84
+ trackArtworkElement: HTMLElement | null;
85
+ trackInfoElement: HTMLElement | null;
86
+ tracks: PlaylistTrack[];
87
+ uniqueId: string;
88
+
89
+ constructor(player: Player, options: Record<string, unknown> = {}) {
90
+ this.player = player;
91
+ this.tracks = [];
92
+ this.initialTracks = Array.isArray(options.tracks) ? (options.tracks as PlaylistTrack[]) : [];
93
+ this.currentIndex = -1;
94
+
95
+ // Generate unique instance ID for this playlist
96
+ this.instanceId = ++playlistInstanceCounter;
97
+ this.uniqueId = `vidply-playlist-${this.instanceId}`;
98
+
99
+ // Options. Spread the caller's bag last so we still support arbitrary
100
+ // pass-through keys, but defaults stay typed.
101
+ this.options = {
102
+ ...options,
103
+ autoAdvance: options.autoAdvance !== false, // Default true
104
+ autoPlayFirst: options.autoPlayFirst !== false, // Default true - auto-play first track on load
105
+ loop: Boolean(options.loop) || false,
106
+ showPanel: options.showPanel !== false, // Default true
107
+ recreatePlayers: Boolean(options.recreatePlayers) || false,
108
+ };
109
+
110
+ // UI elements
111
+ this.container = null;
112
+ this.playlistPanel = null;
113
+ this.trackInfoElement = null;
114
+ this.trackArtworkElement = null;
115
+ this.navigationFeedback = null; // Live region for keyboard navigation feedback
116
+ this.isPanelVisible = this.options.showPanel !== false;
117
+
118
+ // Track change guard to prevent cascade of next() calls
119
+ this.isChangingTrack = false;
120
+
121
+ // Store the host element for player recreation
122
+ this.hostElement = (options.hostElement as HTMLElement | null | undefined) ?? null;
123
+ this.PlayerClass = (options.PlayerClass as PlayerConstructor | null | undefined) ?? null;
124
+
125
+ // Bind methods once so the same reference is used for both on() and off().
126
+ // Binding inline at registration time (e.g. `.bind(this)` in init()) would
127
+ // create a fresh function each call that off() can never match, leaking the
128
+ // listener after destroy().
129
+ this.handleTrackEnd = this.handleTrackEnd.bind(this);
130
+ this.handleTrackError = this.handleTrackError.bind(this);
131
+ this.handlePlaybackStateChange = this.handlePlaybackStateChange.bind(this);
132
+ this.handleFullscreenChange = this.handleFullscreenChange.bind(this);
133
+ this.handleAudioDescriptionChange = this.handleAudioDescriptionChange.bind(this);
134
+
135
+ // Register this playlist manager with the player
136
+ this.player.playlistManager = this;
137
+
138
+ // Initialize
139
+ this.init();
140
+
141
+ // Update controls to add playlist buttons
142
+ this.updatePlayerControls();
143
+
144
+ // Load tracks if provided in options (after UI is ready)
145
+ if (this.initialTracks.length > 0) {
146
+ this.loadPlaylist(this.initialTracks);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Determine the media type for a track
152
+ * @param {Object} track - Track object
153
+ * @returns {string} - 'audio', 'video', 'youtube', 'vimeo', 'soundcloud', 'hls', 'dash'
154
+ */
155
+ getTrackMediaType(track: PlaylistTrack) {
156
+ const src = track.src || '';
157
+
158
+ if (src.includes('youtube.com') || src.includes('youtu.be')) {
159
+ return 'youtube';
160
+ }
161
+ if (src.includes('vimeo.com')) {
162
+ return 'vimeo';
163
+ }
164
+ if (src.includes('soundcloud.com') || src.includes('api.soundcloud.com')) {
165
+ return 'soundcloud';
166
+ }
167
+ if (src.includes('.m3u8')) {
168
+ return 'hls';
169
+ }
170
+ if (src.includes('.mpd')) {
171
+ return 'dash';
172
+ }
173
+ if (track.type && track.type.startsWith('audio/')) {
174
+ return 'audio';
175
+ }
176
+ // Default to video for video types or unknown
177
+ return 'video';
178
+ }
179
+
180
+ /**
181
+ * Recreate the player with the appropriate element type for the track
182
+ * @param {Object} track - Track to load
183
+ * @param {boolean} autoPlay - Whether to auto-play after creation
184
+ */
185
+ async recreatePlayerForTrack(track: PlaylistTrack, autoPlay = false) {
186
+ if (!this.hostElement || !this.PlayerClass) {
187
+ console.warn('VidPly Playlist: Cannot recreate player - missing hostElement or PlayerClass');
188
+ return false;
189
+ }
190
+
191
+ const mediaType = this.getTrackMediaType(track);
192
+ // SoundCloud uses an iframe widget, so it doesn't need an audio element
193
+ // Only local audio files need an actual <audio> element
194
+ const elementType = (mediaType === 'audio') ? 'audio' : 'video';
195
+
196
+ // Store playlist panel state
197
+ const wasVisible = this.isPanelVisible;
198
+ const savedTracks = [...this.tracks]; // Keep track data
199
+ const savedIndex = this.currentIndex;
200
+
201
+ // Detach all playlist UI elements from DOM (keep references)
202
+ // These will be reattached to the new player container
203
+ if (this.trackArtworkElement && this.trackArtworkElement.parentNode) {
204
+ this.trackArtworkElement.parentNode.removeChild(this.trackArtworkElement);
205
+ }
206
+ if (this.trackInfoElement && this.trackInfoElement.parentNode) {
207
+ this.trackInfoElement.parentNode.removeChild(this.trackInfoElement);
208
+ }
209
+ if (this.navigationFeedback && this.navigationFeedback.parentNode) {
210
+ this.navigationFeedback.parentNode.removeChild(this.navigationFeedback);
211
+ }
212
+ if (this.playlistPanel && this.playlistPanel.parentNode) {
213
+ this.playlistPanel.parentNode.removeChild(this.playlistPanel);
214
+ }
215
+
216
+ // Preserve existing player options so recreated players behave
217
+ // consistently. We use a loose `Record<string, unknown>` because
218
+ // PlayerOptions includes optional fields and an open string-indexed
219
+ // bag of pass-through values that the spread flattens.
220
+ const preservedPlayerOptions: Record<string, unknown> =
221
+ this.player?.options ? { ...this.player.options } : {};
222
+
223
+ // Remove event listeners before destroying
224
+ if (this.player) {
225
+ this.player.off('ended', this.handleTrackEnd);
226
+ this.player.off('error', this.handleTrackError);
227
+ // Detach the back-reference *before* destroying the player.
228
+ // Player.destroy() cascade-destroys its `playlistManager`, but here
229
+ // we're swapping the player out and the PlaylistManager owns a UI
230
+ // (panel, track info, artwork) that must survive the swap. Without
231
+ // this detach, the cascade would clear() the panel and the playlist
232
+ // visually disappears when the user clicks an audio track in a
233
+ // mixed playlist.
234
+ this.player.playlistManager = null;
235
+ this.player.destroy();
236
+ }
237
+
238
+ // Clear the host element
239
+ this.hostElement.innerHTML = '';
240
+
241
+ // Create new media element with appropriate type
242
+ const mediaElement = document.createElement(elementType);
243
+ // Respect configured preload (playlists should behave like single videos even with deferLoad)
244
+ const preloadValue = (preservedPlayerOptions.preload as string | undefined) || 'metadata';
245
+ mediaElement.setAttribute('preload', preloadValue);
246
+
247
+ // For video elements with local media, set poster
248
+ if (elementType === 'video' && track.poster &&
249
+ (mediaType === 'video' || mediaType === 'hls' || mediaType === 'dash')) {
250
+ mediaElement.setAttribute('poster', track.poster);
251
+ }
252
+
253
+ // For external renderers (YouTube, Vimeo, SoundCloud, HLS, DASH), don't add source
254
+ // The renderer will handle the source directly
255
+ const isExternalRenderer = ['youtube', 'vimeo', 'soundcloud', 'hls', 'dash'].includes(mediaType);
256
+
257
+ if (!isExternalRenderer) {
258
+ // Add source for HTML5 media
259
+ const source = document.createElement('source');
260
+ source.src = track.src || '';
261
+ if (track.type) {
262
+ source.type = track.type;
263
+ }
264
+ mediaElement.appendChild(source);
265
+
266
+ // Add tracks (captions, chapters, etc.)
267
+ if (track.tracks && track.tracks.length > 0) {
268
+ track.tracks.forEach((trackConfig: PlaylistTextTrack) => {
269
+ const trackEl = document.createElement('track');
270
+ trackEl.src = trackConfig.src || '';
271
+ trackEl.kind = trackConfig.kind || 'captions';
272
+ trackEl.srclang = trackConfig.srclang || 'en';
273
+ trackEl.label = trackConfig.label || trackConfig.srclang || '';
274
+ if (trackConfig.default) {
275
+ trackEl.default = true;
276
+ }
277
+ mediaElement.appendChild(trackEl);
278
+ });
279
+ }
280
+ }
281
+
282
+ this.hostElement.appendChild(mediaElement);
283
+
284
+ // Create new player with the media element
285
+ // Pass the source for external renderers via options
286
+ const playerOptions = {
287
+ mediaType: elementType,
288
+ poster: track.poster,
289
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
290
+ audioDescriptionDuration: track.audioDescriptionDuration || null,
291
+ signLanguageSrc: track.signLanguageSrc || null
292
+ };
293
+ // Merge back preserved options (so deferLoad/preload/etc remain active)
294
+ Object.assign(playerOptions, preservedPlayerOptions);
295
+
296
+ this.player = new this.PlayerClass(mediaElement, playerOptions);
297
+
298
+ // Re-register playlist manager
299
+ this.player.playlistManager = this;
300
+
301
+ // Wait for player to be ready
302
+ await new Promise<void>(resolve => {
303
+ this.player.on('ready', () => resolve());
304
+ });
305
+
306
+ // Re-attach event listeners
307
+ this.player.on('ended', this.handleTrackEnd);
308
+ this.player.on('error', this.handleTrackError);
309
+
310
+ // Re-attach all playlist UI elements to the new player's container
311
+ if (this.player.container) {
312
+ // Track artwork goes before video wrapper
313
+ if (this.trackArtworkElement) {
314
+ const videoWrapper = this.player.container.querySelector('.vidply-video-wrapper');
315
+ if (videoWrapper) {
316
+ this.player.container.insertBefore(this.trackArtworkElement, videoWrapper);
317
+ } else {
318
+ this.player.container.appendChild(this.trackArtworkElement);
319
+ }
320
+ }
321
+ // Track info
322
+ if (this.trackInfoElement) {
323
+ this.player.container.appendChild(this.trackInfoElement);
324
+ }
325
+ // Navigation feedback (screen reader only)
326
+ if (this.navigationFeedback) {
327
+ this.player.container.appendChild(this.navigationFeedback);
328
+ }
329
+ // Playlist panel
330
+ if (this.playlistPanel) {
331
+ this.player.container.appendChild(this.playlistPanel);
332
+ }
333
+ }
334
+
335
+ // Update container reference
336
+ this.container = this.player.container;
337
+
338
+ // Update controls (adds playlist prev/next buttons)
339
+ this.updatePlayerControls();
340
+
341
+ // Restore tracks data (we kept it during recreation)
342
+ this.tracks = savedTracks;
343
+ this.currentIndex = savedIndex;
344
+
345
+ // Update playlist UI to reflect current state
346
+ this.updatePlaylistUI();
347
+
348
+ // Restore playlist panel visibility
349
+ this.isPanelVisible = wasVisible;
350
+ if (this.playlistPanel) {
351
+ this.playlistPanel.style.display = wasVisible ? '' : 'none';
352
+ }
353
+
354
+ // For external renderers, load the track via player.load()
355
+ // For HTML5, the source is already set on the element
356
+ if (isExternalRenderer) {
357
+ this.player.load({
358
+ src: track.src ?? '',
359
+ type: track.type,
360
+ poster: track.poster,
361
+ tracks: track.tracks || [],
362
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
363
+ signLanguageSrc: track.signLanguageSrc || null
364
+ });
365
+ } else {
366
+ this.player.load({
367
+ src: track.src ?? '',
368
+ type: track.type,
369
+ poster: track.poster,
370
+ tracks: track.tracks || [],
371
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
372
+ signLanguageSrc: track.signLanguageSrc || null
373
+ });
374
+ }
375
+
376
+ // Auto-play if requested
377
+ if (autoPlay) {
378
+ setTimeout(() => {
379
+ this.player.play();
380
+ }, 100);
381
+ }
382
+
383
+ return true;
384
+ }
385
+
386
+ init() {
387
+ // Listen for track end
388
+ this.player.on('ended', this.handleTrackEnd);
389
+ this.player.on('error', this.handleTrackError);
390
+
391
+ // Listen for playback state changes to show/hide playlist in fullscreen.
392
+ // Handlers are pre-bound in the constructor so destroy() can detach them.
393
+ this.player.on('play', this.handlePlaybackStateChange);
394
+ this.player.on('pause', this.handlePlaybackStateChange);
395
+ this.player.on('ended', this.handlePlaybackStateChange);
396
+ // Use fullscreenchange event which is what the player actually emits
397
+ this.player.on('fullscreenchange', this.handleFullscreenChange);
398
+
399
+ // Listen for audio description state changes to update duration displays
400
+ this.player.on('audiodescriptionenabled', this.handleAudioDescriptionChange);
401
+ this.player.on('audiodescriptiondisabled', this.handleAudioDescriptionChange);
402
+
403
+ // Create UI if needed
404
+ if (this.options.showPanel) {
405
+ this.createUI();
406
+ }
407
+
408
+ // Check for data-playlist attribute on player container (only if tracks weren't provided in options)
409
+ if (this.tracks.length === 0 && this.initialTracks.length === 0) {
410
+ this.loadPlaylistFromAttribute();
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Load playlist from data-playlist attribute if present
416
+ */
417
+ loadPlaylistFromAttribute() {
418
+ // Check the original wrapper element for data-playlist
419
+ // Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
420
+ // So we need to go up 3 levels
421
+ if (!this.player.element || !this.player.element.parentElement) {
422
+ return;
423
+ }
424
+
425
+ const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
426
+ const playerContainer = videoWrapper.parentElement; // .vidply-player
427
+ const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
428
+
429
+ if (!originalElement) {
430
+ return;
431
+ }
432
+
433
+ // Load playlist options from data attributes
434
+ this.loadOptionsFromAttributes(originalElement);
435
+
436
+ const playlistData = originalElement.getAttribute('data-playlist');
437
+ if (!playlistData) {
438
+ return;
439
+ }
440
+
441
+ try {
442
+ const tracks = JSON.parse(playlistData);
443
+ if (Array.isArray(tracks) && tracks.length > 0) {
444
+ this.loadPlaylist(tracks);
445
+ } else {
446
+ console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
447
+ }
448
+ } catch (error) {
449
+ console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Load playlist options from data attributes
455
+ * @param {HTMLElement} element - Element to read attributes from
456
+ */
457
+ loadOptionsFromAttributes(element: HTMLElement) {
458
+ // data-playlist-auto-advance
459
+ const autoAdvance = element.getAttribute('data-playlist-auto-advance');
460
+ if (autoAdvance !== null) {
461
+ this.options.autoAdvance = autoAdvance === 'true';
462
+ }
463
+
464
+ // data-playlist-auto-play-first
465
+ const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
466
+ if (autoPlayFirst !== null) {
467
+ this.options.autoPlayFirst = autoPlayFirst === 'true';
468
+ }
469
+
470
+ // data-playlist-loop
471
+ const loop = element.getAttribute('data-playlist-loop');
472
+ if (loop !== null) {
473
+ this.options.loop = loop === 'true';
474
+ }
475
+
476
+ // data-playlist-show-panel
477
+ const showPanel = element.getAttribute('data-playlist-show-panel');
478
+ if (showPanel !== null) {
479
+ this.options.showPanel = showPanel === 'true';
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Update player controls to add playlist navigation buttons
485
+ */
486
+ updatePlayerControls() {
487
+ if (!this.player.controlBar) return;
488
+
489
+ const controlBar = this.player.controlBar;
490
+
491
+ // Clear existing controls content (except the element itself)
492
+ controlBar.element.innerHTML = '';
493
+
494
+ // Recreate controls with playlist buttons now available
495
+ controlBar.createControls();
496
+
497
+ // Reattach events for the new controls
498
+ controlBar.attachEvents();
499
+ controlBar.setupAutoHide();
500
+ }
501
+
502
+ /**
503
+ * Load a playlist
504
+ * @param {Array} tracks - Array of track objects
505
+ */
506
+ loadPlaylist(tracks: PlaylistTrack[]) {
507
+ this.tracks = tracks;
508
+ this.currentIndex = -1;
509
+
510
+ // Add playlist class to container
511
+ if (this.container) {
512
+ this.container.classList.add('vidply-has-playlist');
513
+ }
514
+
515
+ // Update UI
516
+ if (this.playlistPanel) {
517
+ this.renderPlaylist();
518
+ }
519
+
520
+ // Auto-play first track (if enabled)
521
+ if (tracks.length > 0) {
522
+ if (this.options.autoPlayFirst) {
523
+ this.play(0);
524
+ } else {
525
+ // Behave like a single video: load the first track (metadata/manifest)
526
+ // but do not start playback.
527
+ void this.loadTrack(0).catch(() => {
528
+ // ignore
529
+ });
530
+ }
531
+ }
532
+
533
+ // Update visibility based on current state
534
+ this.updatePlaylistVisibilityInFullscreen();
535
+ }
536
+
537
+ /**
538
+ * Load a track without playing
539
+ * This is the playlist equivalent of a "single video initialized but not started yet":
540
+ * it updates UI selection and loads the media into the player so metadata/manifests
541
+ * and feature managers can be ready, but it does not start playback.
542
+ * @param {number} index - Track index
543
+ */
544
+ async loadTrack(index: number) {
545
+ if (index < 0 || index >= this.tracks.length) {
546
+ console.warn('VidPly Playlist: Invalid track index', index);
547
+ return;
548
+ }
549
+
550
+ const track = this.tracks[index];
551
+ if (!track) return;
552
+
553
+ // Always update UI immediately (poster, buttons, duration, etc.).
554
+ // Note: this is UI-only; actual media loading is performed by player.load() below.
555
+ this.selectTrack(index);
556
+
557
+ // Set guard flag to prevent cascade of next() calls during track change
558
+ this.isChangingTrack = true;
559
+
560
+ // Check if we should recreate the player for this track type
561
+ if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
562
+ const currentMediaType = this.player ?
563
+ (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
564
+ const newMediaType = this.getTrackMediaType(track);
565
+ const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
566
+
567
+ // Recreate if element type is different
568
+ if (currentMediaType !== newElementType) {
569
+ await this.recreatePlayerForTrack(track, false);
570
+ // Re-apply selection to the newly created player (poster/tracks/buttons)
571
+ this.selectTrack(index);
572
+
573
+ // Emit event
574
+ this.player.emit('playlisttrackchange', {
575
+ index: index,
576
+ item: track,
577
+ total: this.tracks.length
578
+ });
579
+
580
+ // Clear guard flag
581
+ setTimeout(() => {
582
+ this.isChangingTrack = false;
583
+ }, 150);
584
+ return;
585
+ }
586
+ }
587
+
588
+ const loadPromise = this.player.load({
589
+ src: track.src ?? '',
590
+ type: track.type,
591
+ poster: track.poster,
592
+ tracks: track.tracks || [],
593
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
594
+ signLanguageSrc: track.signLanguageSrc || null,
595
+ signLanguageSources: track.signLanguageSources || {}
596
+ });
597
+
598
+ // For playlist UX parity with single videos: fetch metadata/manifest now,
599
+ // but do not start playback.
600
+ if (this.player?.options?.deferLoad && typeof this.player.ensureLoaded === 'function') {
601
+ Promise.resolve(loadPromise)
602
+ .then(() => this.player?.ensureLoaded?.())
603
+ .catch(() => {
604
+ // ignore
605
+ });
606
+ }
607
+
608
+ // Emit event
609
+ this.player.emit('playlisttrackchange', {
610
+ index: index,
611
+ item: track,
612
+ total: this.tracks.length
613
+ });
614
+
615
+ // Clear guard flag after a short delay to ensure track is loaded
616
+ setTimeout(() => {
617
+ this.isChangingTrack = false;
618
+ }, 150);
619
+ }
620
+
621
+ /**
622
+ * Select a track (UI/selection only; does NOT set the media src / does NOT initialize renderer)
623
+ *
624
+ * In "B always" playlist mode, you typically want `loadTrack()` on selection so the
625
+ * selected item behaves like a single video (metadata/manifest loaded, features ready)
626
+ * without auto-playing.
627
+ * @param {number} index - Track index
628
+ */
629
+ selectTrack(index: number) {
630
+ if (index < 0 || index >= this.tracks.length) {
631
+ console.warn('VidPly Playlist: Invalid track index', index);
632
+ return;
633
+ }
634
+
635
+ const track = this.tracks[index];
636
+ if (!track) return;
637
+ this.currentIndex = index;
638
+
639
+ // Apply per-track metadata without touching the media source.
640
+ // This ensures poster + feature buttons (chapters/captions/transcript/sign-language)
641
+ // can be updated instantly even before any media network activity happens.
642
+ try {
643
+ // Poster for video element — validate the URL from the manifest
644
+ // before we hand it to `<video>.poster`. Without the allow-list an
645
+ // attacker-controlled `javascript:` or `data:text/html` value could
646
+ // reach the attribute.
647
+ if (this.player?.element?.tagName === 'VIDEO') {
648
+ if (track.poster) {
649
+ const resolved: string = typeof this.player.resolvePosterPath === 'function'
650
+ ? this.player.resolvePosterPath(track.poster)
651
+ : track.poster;
652
+ const posterUrl = sanitizePosterUrl(resolved);
653
+ if (posterUrl) {
654
+ (this.player.element as HTMLVideoElement).poster = posterUrl;
655
+ (this.player as Player & { applyPosterAspectRatio?: (url: string) => void }).applyPosterAspectRatio?.(posterUrl);
656
+ } else {
657
+ this.player.element.removeAttribute('poster');
658
+ }
659
+ } else {
660
+ this.player.element.removeAttribute('poster');
661
+ }
662
+ }
663
+
664
+ // Update sign language / audio description sources (used for button visibility)
665
+ this.player.audioDescriptionSrc = track.audioDescriptionSrc || null;
666
+ this.player.signLanguageSrc = track.signLanguageSrc || null;
667
+ this.player.signLanguageSources = track.signLanguageSources || {};
668
+
669
+ // Fill duration early for UI (progress/time display) without loading media
670
+ if (track.duration && Number(track.duration) > 0) {
671
+ this.player.state.duration = Number(track.duration);
672
+ }
673
+ // Also sync feature managers (they keep their own copy of sources)
674
+ if (this.player.audioDescriptionManager) {
675
+ this.player.audioDescriptionManager.src = track.audioDescriptionSrc || null;
676
+ // Remember original (non-described) source for switching back later
677
+ this.player.audioDescriptionManager.originalSource = track.src || this.player.originalSrc || null;
678
+ }
679
+ if (this.player.signLanguageManager) {
680
+ this.player.signLanguageManager.src = track.signLanguageSrc || null;
681
+ this.player.signLanguageManager.sources = track.signLanguageSources || {};
682
+ this.player.signLanguageManager.currentLanguage = null;
683
+ }
684
+
685
+ // For audio description switching, remember original source even before first play
686
+ if (track.src && !this.player.originalSrc) {
687
+ this.player.originalSrc = track.src;
688
+ }
689
+
690
+ // Replace <track> elements so captions/chapters/transcript can be detected/loaded
691
+ const existing: HTMLElement[] = Array.from(this.player.element.querySelectorAll('track'));
692
+ existing.forEach(t => t.remove());
693
+
694
+ if (Array.isArray(track.tracks)) {
695
+ track.tracks.forEach((tc: PlaylistTextTrack) => {
696
+ if (!tc?.src) return;
697
+ const el = document.createElement('track');
698
+ el.src = tc.src;
699
+ el.kind = tc.kind || 'captions';
700
+ el.srclang = tc.srclang || 'en';
701
+ el.label = tc.label || tc.srclang || 'Track';
702
+ if (tc.default) el.default = true;
703
+ if (tc.describedSrc) {
704
+ el.setAttribute('data-desc-src', tc.describedSrc);
705
+ }
706
+ this.player.element.appendChild(el);
707
+ });
708
+ }
709
+
710
+ if (typeof this.player.invalidateTrackCache === 'function') {
711
+ this.player.invalidateTrackCache();
712
+ }
713
+
714
+ // Re-scan described-track metadata for the AudioDescriptionManager. The
715
+ // manager is no longer eagerly loaded on content-less players, so if this
716
+ // track is the first to introduce described content, pull the chunk in
717
+ // lazily before re-scanning.
718
+ const reinitAudioDescription = (adm: Player['audioDescriptionManager']) => {
719
+ if (!adm || typeof adm.initFromSourceElements !== 'function') return;
720
+ try {
721
+ adm.captionTracks = [];
722
+ adm.initFromSourceElements(this.player.sourceElements, this.player.trackElements);
723
+ } catch {
724
+ // ignore
725
+ }
726
+ };
727
+ if (this.player.audioDescriptionManager) {
728
+ reinitAudioDescription(this.player.audioDescriptionManager);
729
+ } else if (this.player.hasAudioDescriptionContent?.()) {
730
+ void this.player.ensureAudioDescriptionManager()
731
+ .then(reinitAudioDescription)
732
+ .catch(() => { /* ignore */ });
733
+ }
734
+
735
+ // Refresh caption/transcript managers so menus reflect newly injected <track> elements
736
+ // (important when we defer MP4/MP3 loading but still want VTT-based UI to work).
737
+ if (this.player.captionManager && typeof this.player.captionManager.loadTracks === 'function') {
738
+ try {
739
+ this.player.captionManager.tracks = [];
740
+ this.player.captionManager.currentTrack = null;
741
+ this.player.captionManager.loadTracks();
742
+ } catch {
743
+ // ignore
744
+ }
745
+ }
746
+
747
+ // TranscriptManager reads from TextTracks too; it will be correct after media starts.
748
+ // For now, we just ensure control bar is rebuilt so the button is present.
749
+
750
+ // Rebuild controls so feature buttons appear immediately
751
+ if (typeof this.player.updateControlBar === 'function') {
752
+ this.player.updateControlBar();
753
+ }
754
+ } catch {
755
+ // ignore preview errors; selection should still work
756
+ }
757
+
758
+ this.updateTrackInfo(track);
759
+ this.updatePlaylistUI();
760
+
761
+ this.player.emit('playlisttrackselect', {
762
+ index,
763
+ item: track,
764
+ total: this.tracks.length
765
+ });
766
+ }
767
+
768
+ /**
769
+ * Play a specific track
770
+ * @param {number} index - Track index
771
+ * @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
772
+ */
773
+ async play(index: number, _userInitiated = false) {
774
+ if (index < 0 || index >= this.tracks.length) {
775
+ console.warn('VidPly Playlist: Invalid track index', index);
776
+ return;
777
+ }
778
+
779
+ const track = this.tracks[index];
780
+ if (!track) return;
781
+
782
+ // Set guard flag to prevent cascade of next() calls during track change
783
+ this.isChangingTrack = true;
784
+
785
+ // Update current index
786
+ this.currentIndex = index;
787
+
788
+ // Check if we should recreate the player for this track type
789
+ if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
790
+ const currentMediaType = this.player ?
791
+ (this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
792
+ const newMediaType = this.getTrackMediaType(track);
793
+ const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
794
+
795
+ // Recreate if element type is different
796
+ if (currentMediaType !== newElementType) {
797
+ await this.recreatePlayerForTrack(track, true); // true = autoPlay
798
+ // Update UI after recreation
799
+ this.updateTrackInfo(track);
800
+ this.updatePlaylistUI();
801
+
802
+ // Emit event
803
+ this.player.emit('playlisttrackchange', {
804
+ index: index,
805
+ item: track,
806
+ total: this.tracks.length
807
+ });
808
+
809
+ // Clear guard flag
810
+ setTimeout(() => {
811
+ this.isChangingTrack = false;
812
+ }, 150);
813
+ return;
814
+ }
815
+ }
816
+
817
+ // Load track into player (normal path)
818
+ // If audio description was toggled before the first play, load the described source directly.
819
+ let srcToLoad = track.src;
820
+ if (this.player?.audioDescriptionManager?.desiredState && track.audioDescriptionSrc) {
821
+ this.player.originalSrc = track.src ?? null;
822
+ this.player.audioDescriptionManager.originalSource = track.src ?? null;
823
+ this.player.audioDescriptionManager.src = track.audioDescriptionSrc;
824
+ srcToLoad = track.audioDescriptionSrc;
825
+ }
826
+
827
+ this.player.load({
828
+ src: srcToLoad ?? '',
829
+ type: track.type,
830
+ poster: track.poster,
831
+ tracks: track.tracks || [],
832
+ audioDescriptionSrc: track.audioDescriptionSrc || null,
833
+ signLanguageSrc: track.signLanguageSrc || null,
834
+ signLanguageSources: track.signLanguageSources || {}
835
+ });
836
+
837
+ // Update UI
838
+ this.updateTrackInfo(track);
839
+ this.updatePlaylistUI();
840
+
841
+ // Emit event
842
+ this.player.emit('playlisttrackchange', {
843
+ index: index,
844
+ item: track,
845
+ total: this.tracks.length
846
+ });
847
+
848
+ // Auto-play and clear guard flag after playback starts
849
+ setTimeout(() => {
850
+ this.player.play();
851
+ // Clear guard flag after a short delay to ensure track has started
852
+ setTimeout(() => {
853
+ this.isChangingTrack = false;
854
+ }, 50);
855
+ }, 100);
856
+ }
857
+
858
+ /**
859
+ * Play next track
860
+ */
861
+ next() {
862
+ let nextIndex = this.currentIndex + 1;
863
+
864
+ if (nextIndex >= this.tracks.length) {
865
+ if (this.options.loop) {
866
+ nextIndex = 0;
867
+ } else {
868
+ return;
869
+ }
870
+ }
871
+
872
+ this.play(nextIndex);
873
+ }
874
+
875
+ /**
876
+ * Play previous track
877
+ */
878
+ previous() {
879
+ let prevIndex = this.currentIndex - 1;
880
+
881
+ if (prevIndex < 0) {
882
+ if (this.options.loop) {
883
+ prevIndex = this.tracks.length - 1;
884
+ } else {
885
+ return;
886
+ }
887
+ }
888
+
889
+ this.play(prevIndex);
890
+ }
891
+
892
+ /**
893
+ * Handle track end
894
+ */
895
+ handleTrackEnd() {
896
+ // Don't auto-advance if we're already in the process of changing tracks
897
+ // This prevents a cascade of next() calls when loading a new track triggers an 'ended' event
898
+ if (this.isChangingTrack) {
899
+ return;
900
+ }
901
+
902
+ if (this.options.autoAdvance) {
903
+ this.next();
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Check if a source URL requires an external renderer
909
+ * @param {string} src - Source URL
910
+ * @returns {boolean}
911
+ */
912
+ isExternalRendererUrl(src: string | null | undefined) {
913
+ if (!src) return false;
914
+ return src.includes('youtube.com') ||
915
+ src.includes('youtu.be') ||
916
+ src.includes('vimeo.com') ||
917
+ src.includes('soundcloud.com') ||
918
+ src.includes('api.soundcloud.com') ||
919
+ src.includes('.m3u8') ||
920
+ src.includes('.mpd');
921
+ }
922
+
923
+ /**
924
+ * Handle track error
925
+ */
926
+ handleTrackError(e: unknown) {
927
+ // Don't auto-advance for external renderer tracks
928
+ // External renderers (YouTube, Vimeo, SoundCloud, HLS, DASH) may trigger HTML5 errors
929
+ // that should be ignored since the external renderer handles playback
930
+ const currentTrack = this.getCurrentTrack();
931
+ if (currentTrack && currentTrack.src && this.isExternalRendererUrl(currentTrack.src)) {
932
+ // Silently ignore errors for external renderer tracks
933
+ return;
934
+ }
935
+
936
+ // Don't auto-advance if we're in the process of changing tracks
937
+ // This prevents a cascade of next() calls when switching between renderer types
938
+ if (this.isChangingTrack) {
939
+ return;
940
+ }
941
+
942
+ console.error('VidPly Playlist: Track error', e);
943
+
944
+ // Try next track
945
+ if (this.options.autoAdvance) {
946
+ setTimeout(() => {
947
+ this.next();
948
+ }, 1000);
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Handle playback state changes (for fullscreen playlist visibility)
954
+ */
955
+ handlePlaybackStateChange() {
956
+ this.updatePlaylistVisibilityInFullscreen();
957
+ }
958
+
959
+ /**
960
+ * Handle fullscreen state changes
961
+ */
962
+ handleFullscreenChange() {
963
+ // Use a small delay to ensure fullscreen state is fully applied
964
+ setTimeout(() => {
965
+ this.updatePlaylistVisibilityInFullscreen();
966
+ }, 50);
967
+ }
968
+
969
+ /**
970
+ * Handle audio description state changes
971
+ * Updates duration displays to show audio-described version duration when AD is enabled
972
+ */
973
+ handleAudioDescriptionChange() {
974
+ const currentTrack = this.getCurrentTrack();
975
+ if (!currentTrack) return;
976
+
977
+ // Update the track info display with the appropriate duration
978
+ this.updateTrackInfo(currentTrack);
979
+
980
+ // Update the playlist UI to reflect duration changes (aria-labels)
981
+ this.updatePlaylistUI();
982
+
983
+ // Update visual duration elements in playlist panel
984
+ this.updatePlaylistDurations();
985
+ }
986
+
987
+ /**
988
+ * Update the visual duration displays in the playlist panel
989
+ * Called when audio description state changes
990
+ */
991
+ updatePlaylistDurations() {
992
+ if (!this.playlistPanel) return;
993
+
994
+ const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
995
+
996
+ items.forEach((item: Element, index: number) => {
997
+ const track = this.tracks[index];
998
+ if (!track) return;
999
+
1000
+ const effectiveDuration = this.getEffectiveDuration(track);
1001
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
1002
+
1003
+ // Update duration badge on thumbnail (if exists)
1004
+ const durationBadge = item.querySelector('.vidply-playlist-duration-badge');
1005
+ if (durationBadge) {
1006
+ durationBadge.textContent = trackDuration;
1007
+ }
1008
+
1009
+ // Update inline duration (if exists)
1010
+ const inlineDuration = item.querySelector('.vidply-playlist-item-duration');
1011
+ if (inlineDuration) {
1012
+ inlineDuration.textContent = trackDuration;
1013
+ }
1014
+ });
1015
+ }
1016
+
1017
+ /**
1018
+ * Get the effective duration for a track based on audio description state
1019
+ * @param {Object} track - Track object
1020
+ * @returns {number|null} - Duration in seconds or null if not available
1021
+ */
1022
+ getEffectiveDuration(track: PlaylistTrack) {
1023
+ if (!track) return null;
1024
+
1025
+ const isAudioDescriptionEnabled = this.player.state.audioDescriptionEnabled;
1026
+
1027
+ // If audio description is enabled and track has audioDescriptionDuration, use it
1028
+ if (isAudioDescriptionEnabled && track.audioDescriptionDuration) {
1029
+ return track.audioDescriptionDuration;
1030
+ }
1031
+
1032
+ // Otherwise use regular duration
1033
+ return track.duration || null;
1034
+ }
1035
+
1036
+ /**
1037
+ * Update playlist visibility based on fullscreen and playback state
1038
+ * In fullscreen: show when paused/not started, hide when playing
1039
+ * Outside fullscreen: respect original panel visibility setting
1040
+ */
1041
+ updatePlaylistVisibilityInFullscreen() {
1042
+ const playlistPanel = this.playlistPanel;
1043
+ if (!playlistPanel || !this.tracks.length) return;
1044
+
1045
+ const isFullscreen = this.player.state.fullscreen;
1046
+ const isPlaying = this.player.state.playing;
1047
+
1048
+ if (isFullscreen) {
1049
+ // In fullscreen: show only when not playing (paused or not started)
1050
+ // Check playing state explicitly since paused might not be set initially
1051
+ if (!isPlaying) {
1052
+ playlistPanel.classList.add('vidply-playlist-fullscreen-visible');
1053
+ playlistPanel.style.display = 'block';
1054
+ } else {
1055
+ playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
1056
+ // Add a smooth fade out with delay to match CSS transition
1057
+ setTimeout(() => {
1058
+ // Double-check state hasn't changed before hiding
1059
+ if (this.player.state.playing && this.player.state.fullscreen) {
1060
+ playlistPanel.style.display = 'none';
1061
+ }
1062
+ }, 300); // Match CSS transition duration
1063
+ }
1064
+ } else {
1065
+ // Outside fullscreen: restore original behavior
1066
+ playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
1067
+ if (this.isPanelVisible && this.tracks.length > 0) {
1068
+ playlistPanel.style.display = 'block';
1069
+ } else {
1070
+ playlistPanel.style.display = 'none';
1071
+ }
1072
+ }
1073
+ }
1074
+
1075
+ /**
1076
+ * Create playlist UI
1077
+ */
1078
+ createUI() {
1079
+ // Find player container
1080
+ this.container = this.player.container;
1081
+
1082
+ if (!this.container) {
1083
+ console.warn('VidPly Playlist: No container found');
1084
+ return;
1085
+ }
1086
+
1087
+ // Track artwork element (shows album art/poster for audio tracks).
1088
+ // Important: in mixed playlists the player may start as <video> and later recreate to <audio>,
1089
+ // so we create this lazily in `updateTrackArtwork()` when we actually have an audio element.
1090
+
1091
+ // Create track info element (shows current track)
1092
+ this.trackInfoElement = DOMUtils.createElement('div', {
1093
+ className: 'vidply-track-info',
1094
+ attributes: {
1095
+ role: 'status'
1096
+ }
1097
+ });
1098
+ this.trackInfoElement.style.display = 'none';
1099
+
1100
+ this.container.appendChild(this.trackInfoElement);
1101
+
1102
+ // Create navigation feedback live region
1103
+ this.navigationFeedback = DOMUtils.createElement('div', {
1104
+ className: 'vidply-sr-only',
1105
+ attributes: {
1106
+ role: 'status',
1107
+ 'aria-live': 'polite',
1108
+ 'aria-atomic': 'true'
1109
+ }
1110
+ });
1111
+ this.container.appendChild(this.navigationFeedback);
1112
+
1113
+ // Create playlist panel with proper landmark
1114
+ this.playlistPanel = DOMUtils.createElement('div', {
1115
+ className: 'vidply-playlist-panel',
1116
+ attributes: {
1117
+ id: `${this.uniqueId}-panel`,
1118
+ role: 'region',
1119
+ 'aria-label': i18n.t('playlist.title'),
1120
+ 'aria-labelledby': `${this.uniqueId}-heading`
1121
+ }
1122
+ });
1123
+ this.playlistPanel.style.display = this.isPanelVisible ? 'none' : 'none'; // Will be shown when playlist is loaded
1124
+
1125
+ this.container.appendChild(this.playlistPanel);
1126
+ }
1127
+
1128
+ /**
1129
+ * Update track info display
1130
+ */
1131
+ updateTrackInfo(track: PlaylistTrack) {
1132
+ if (!this.trackInfoElement) return;
1133
+
1134
+ const trackNumber = this.currentIndex + 1;
1135
+ const totalTracks = this.tracks.length;
1136
+ const trackTitle = track.title || i18n.t('playlist.untitled');
1137
+ const trackArtist = track.artist || '';
1138
+
1139
+ // Use effective duration (audio description duration when AD is enabled)
1140
+ const effectiveDuration = this.getEffectiveDuration(track);
1141
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
1142
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
1143
+
1144
+ // Screen reader announcement - include duration if available
1145
+ const artistPart = trackArtist ? i18n.t('playlist.by') + trackArtist : '';
1146
+ const durationPart = trackDurationReadable ? `. ${trackDurationReadable}` : '';
1147
+ const announcement = i18n.t('playlist.nowPlaying', {
1148
+ current: trackNumber,
1149
+ total: totalTracks,
1150
+ title: trackTitle,
1151
+ artist: artistPart
1152
+ }) + durationPart;
1153
+
1154
+ const trackOfText = i18n.t('playlist.trackOf', {
1155
+ current: trackNumber,
1156
+ total: totalTracks
1157
+ });
1158
+
1159
+ // Build duration HTML if available
1160
+ const durationHtml = trackDuration
1161
+ ? `<span class="vidply-track-duration" aria-hidden="true">${DOMUtils.escapeHTML(trackDuration)}</span>`
1162
+ : '';
1163
+
1164
+ // Get description if available
1165
+ const trackDescription = track.description || '';
1166
+
1167
+ this.trackInfoElement.innerHTML = `
1168
+ <span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
1169
+ <div class="vidply-track-header" aria-hidden="true">
1170
+ <span class="vidply-track-number">${DOMUtils.escapeHTML(trackOfText)}</span>
1171
+ ${durationHtml}
1172
+ </div>
1173
+ <div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
1174
+ ${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
1175
+ ${trackDescription ? `<div class="vidply-track-description" aria-hidden="true">${DOMUtils.escapeHTML(trackDescription)}</div>` : ''}
1176
+ `;
1177
+
1178
+ this.trackInfoElement.style.display = 'block';
1179
+
1180
+ // Update track artwork if available (for audio playlists)
1181
+ this.updateTrackArtwork(track);
1182
+ }
1183
+
1184
+ /**
1185
+ * Update track artwork display (for audio playlists)
1186
+ */
1187
+ updateTrackArtwork(track: PlaylistTrack) {
1188
+ // Only show artwork for audio players.
1189
+ // In mixed playlists we may recreate from <video> -> <audio> later, so ensure the element exists lazily.
1190
+ if (this.player?.element?.tagName !== 'AUDIO') {
1191
+ if (this.trackArtworkElement) {
1192
+ this.trackArtworkElement.style.display = 'none';
1193
+ }
1194
+ return;
1195
+ }
1196
+
1197
+ // Lazily create artwork element once we have an audio element/container.
1198
+ if (!this.trackArtworkElement && this.container) {
1199
+ this.trackArtworkElement = DOMUtils.createElement('div', {
1200
+ className: 'vidply-track-artwork',
1201
+ attributes: {
1202
+ 'aria-hidden': 'true'
1203
+ }
1204
+ });
1205
+ this.trackArtworkElement.style.display = 'none';
1206
+
1207
+ // Insert before video wrapper (if present) for consistent layout.
1208
+ const videoWrapper = this.container.querySelector('.vidply-video-wrapper');
1209
+ if (videoWrapper) {
1210
+ this.container.insertBefore(this.trackArtworkElement, videoWrapper);
1211
+ } else {
1212
+ this.container.appendChild(this.trackArtworkElement);
1213
+ }
1214
+ }
1215
+
1216
+ if (!this.trackArtworkElement) return;
1217
+
1218
+ // A track manifest is attacker-influenced data — a value like
1219
+ // `x); background: url(evil.svg` must not be allowed to break out
1220
+ // of the declaration. `toCssBackgroundImage` returns null when the
1221
+ // URL fails validation, so we leave the element hidden instead of
1222
+ // assigning a dangerous value.
1223
+ const safeBackground = track.poster ? toCssBackgroundImage(track.poster) : null;
1224
+ if (safeBackground) {
1225
+ this.trackArtworkElement.style.backgroundImage = safeBackground;
1226
+ this.trackArtworkElement.style.display = 'block';
1227
+ } else {
1228
+ this.trackArtworkElement.style.backgroundImage = '';
1229
+ this.trackArtworkElement.style.display = 'none';
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Render playlist
1235
+ */
1236
+ renderPlaylist() {
1237
+ if (!this.playlistPanel) return;
1238
+
1239
+ // Clear existing
1240
+ this.playlistPanel.innerHTML = '';
1241
+
1242
+ // Create header
1243
+ const header = DOMUtils.createElement('h2', {
1244
+ className: 'vidply-playlist-header',
1245
+ attributes: {
1246
+ id: `${this.uniqueId}-heading`
1247
+ }
1248
+ });
1249
+ header.textContent = `${i18n.t('playlist.title')} (${this.tracks.length})`;
1250
+ this.playlistPanel.appendChild(header);
1251
+
1252
+ // Add keyboard instructions (visually hidden)
1253
+ const instructions = DOMUtils.createElement('div', {
1254
+ className: 'vidply-sr-only',
1255
+ attributes: {
1256
+ id: `${this.uniqueId}-keyboard-instructions`
1257
+ }
1258
+ });
1259
+ instructions.textContent = i18n.t('playlist.keyboardInstructions');
1260
+ this.playlistPanel.appendChild(instructions);
1261
+
1262
+ // Create list (proper ul element)
1263
+ // Note: role="listbox" requires option children. We use role="none" on <li> elements
1264
+ // so the buttons with role="option" become the semantic children of the listbox.
1265
+ const list = DOMUtils.createElement('ul', {
1266
+ className: 'vidply-playlist-list',
1267
+ attributes: {
1268
+ role: 'listbox',
1269
+ 'aria-labelledby': `${this.uniqueId}-heading`,
1270
+ 'aria-describedby': `${this.uniqueId}-keyboard-instructions`
1271
+ }
1272
+ });
1273
+
1274
+ this.tracks.forEach((track, index) => {
1275
+ const item = this.createPlaylistItem(track, index);
1276
+ list.appendChild(item);
1277
+ });
1278
+
1279
+ this.playlistPanel.appendChild(list);
1280
+
1281
+ // Show panel if it should be visible
1282
+ if (this.isPanelVisible) {
1283
+ this.playlistPanel.style.display = 'block';
1284
+ }
1285
+ }
1286
+
1287
+ /**
1288
+ * Create playlist item element
1289
+ */
1290
+ createPlaylistItem(track: PlaylistTrack, index: number) {
1291
+ const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1292
+ const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1293
+
1294
+ // Use effective duration (audio description duration when AD is enabled)
1295
+ const effectiveDuration = this.getEffectiveDuration(track);
1296
+ const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
1297
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
1298
+ const isActive = index === this.currentIndex;
1299
+
1300
+ // Build accessible label for screen readers
1301
+ // With role="option" and aria-checked, screen reader will announce selection state
1302
+ // Position is already announced via aria-posinset/aria-setsize
1303
+ // Format: "Title by Artist. 3 minutes, 45 seconds."
1304
+ let ariaLabel = `${trackTitle}${trackArtist}`;
1305
+ if (trackDurationReadable) {
1306
+ ariaLabel += `. ${trackDurationReadable}`;
1307
+ }
1308
+
1309
+ // Create list item container
1310
+ // role="none" removes the <li> from the accessibility tree so the button with
1311
+ // role="option" becomes the direct semantic child of the listbox (required by ARIA).
1312
+ const item = DOMUtils.createElement('li', {
1313
+ className: isActive ? 'vidply-playlist-item vidply-playlist-item-active' : 'vidply-playlist-item',
1314
+ attributes: {
1315
+ 'data-playlist-index': String(index),
1316
+ role: 'none'
1317
+ }
1318
+ });
1319
+
1320
+ // Create button wrapper for interactive content
1321
+ const button = DOMUtils.createElement('button', {
1322
+ className: 'vidply-playlist-item-button',
1323
+ attributes: {
1324
+ type: 'button',
1325
+ role: 'option',
1326
+ tabIndex: index === 0 ? '0' : '-1',
1327
+ 'aria-label': ariaLabel,
1328
+ 'aria-posinset': String(index + 1),
1329
+ 'aria-setsize': String(this.tracks.length),
1330
+ 'aria-checked': isActive ? 'true' : 'false'
1331
+ }
1332
+ });
1333
+
1334
+ // Add aria-current if active
1335
+ if (isActive) {
1336
+ button.setAttribute('aria-current', 'true');
1337
+ button.setAttribute('tabIndex', '0'); // Active item should always be tabbable
1338
+ }
1339
+
1340
+ // Thumbnail container with optional duration badge
1341
+ const thumbnailContainer = DOMUtils.createElement('span', {
1342
+ className: 'vidply-playlist-thumbnail-container',
1343
+ attributes: {
1344
+ 'aria-hidden': 'true'
1345
+ }
1346
+ });
1347
+
1348
+ // Thumbnail or icon
1349
+ const thumbnail = DOMUtils.createElement('span', {
1350
+ className: 'vidply-playlist-thumbnail'
1351
+ });
1352
+
1353
+ // Same rule as trackArtworkElement below: only set backgroundImage
1354
+ // when the manifest URL passes the allow-list so a hostile feed
1355
+ // cannot break out of the CSS `url(...)` declaration.
1356
+ const safeThumbnail = track.poster ? toCssBackgroundImage(track.poster) : null;
1357
+ if (safeThumbnail) {
1358
+ thumbnail.style.backgroundImage = safeThumbnail;
1359
+ } else {
1360
+ const icon = createIconElement('music');
1361
+ icon.classList.add('vidply-playlist-thumbnail-icon');
1362
+ thumbnail.appendChild(icon);
1363
+ }
1364
+
1365
+ thumbnailContainer.appendChild(thumbnail);
1366
+
1367
+ // Duration badge on thumbnail (like YouTube) - only show if there's a poster
1368
+ if (trackDuration && track.poster) {
1369
+ const durationBadge = DOMUtils.createElement('span', {
1370
+ className: 'vidply-playlist-duration-badge'
1371
+ });
1372
+ durationBadge.textContent = trackDuration;
1373
+ thumbnailContainer.appendChild(durationBadge);
1374
+ }
1375
+
1376
+ button.appendChild(thumbnailContainer);
1377
+
1378
+ // Info section (title, artist, description)
1379
+ const info = DOMUtils.createElement('span', {
1380
+ className: 'vidply-playlist-item-info',
1381
+ attributes: {
1382
+ 'aria-hidden': 'true'
1383
+ }
1384
+ });
1385
+
1386
+ // Title row with optional inline duration (for when no thumbnail)
1387
+ const titleRow = DOMUtils.createElement('span', {
1388
+ className: 'vidply-playlist-item-title-row'
1389
+ });
1390
+
1391
+ const title = DOMUtils.createElement('span', {
1392
+ className: 'vidply-playlist-item-title'
1393
+ });
1394
+ title.textContent = trackTitle;
1395
+ titleRow.appendChild(title);
1396
+
1397
+ // Inline duration (shown when no poster/thumbnail)
1398
+ if (trackDuration && !track.poster) {
1399
+ const inlineDuration = DOMUtils.createElement('span', {
1400
+ className: 'vidply-playlist-item-duration'
1401
+ });
1402
+ inlineDuration.textContent = trackDuration;
1403
+ titleRow.appendChild(inlineDuration);
1404
+ }
1405
+
1406
+ info.appendChild(titleRow);
1407
+
1408
+ // Artist
1409
+ if (track.artist) {
1410
+ const artist = DOMUtils.createElement('span', {
1411
+ className: 'vidply-playlist-item-artist'
1412
+ });
1413
+ artist.textContent = track.artist;
1414
+ info.appendChild(artist);
1415
+ }
1416
+
1417
+ // Description (truncated)
1418
+ if (track.description) {
1419
+ const description = DOMUtils.createElement('span', {
1420
+ className: 'vidply-playlist-item-description'
1421
+ });
1422
+ description.textContent = track.description;
1423
+ info.appendChild(description);
1424
+ }
1425
+
1426
+ button.appendChild(info);
1427
+
1428
+ // Play icon
1429
+ const playIcon = createIconElement('play');
1430
+ playIcon.classList.add('vidply-playlist-item-icon');
1431
+ playIcon.setAttribute('aria-hidden', 'true');
1432
+ button.appendChild(playIcon);
1433
+
1434
+ // Click handler
1435
+ button.addEventListener('click', () => {
1436
+ const track = this.tracks[index];
1437
+ const isExternalRenderer = this.isExternalRendererUrl(track?.src);
1438
+
1439
+ // Exit fullscreen for external renderer tracks (YouTube, Vimeo, SoundCloud)
1440
+ // These have their own native controls that work better outside vidply's fullscreen
1441
+ if (isExternalRenderer && this.player.state.fullscreen) {
1442
+ this.player.exitFullscreen();
1443
+ // Small delay to let fullscreen exit before loading
1444
+ setTimeout(() => {
1445
+ this.play(index, true);
1446
+ }, 100);
1447
+ } else {
1448
+ this.play(index, true); // User-initiated
1449
+ }
1450
+ });
1451
+
1452
+ // Keyboard handler
1453
+ button.addEventListener('keydown', (e) => {
1454
+ this.handlePlaylistItemKeydown(e, index);
1455
+ });
1456
+
1457
+ // Append button to list item
1458
+ item.appendChild(button);
1459
+
1460
+ return item;
1461
+ }
1462
+
1463
+ /**
1464
+ * Handle keyboard navigation in playlist items
1465
+ */
1466
+ handlePlaylistItemKeydown(e: KeyboardEvent, index: number) {
1467
+ if (!this.playlistPanel) return;
1468
+ const buttons: HTMLElement[] = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item-button'));
1469
+ let newIndex = -1;
1470
+ let announcement = '';
1471
+
1472
+ switch(e.key) {
1473
+ case 'Enter':
1474
+ case ' ':
1475
+ e.preventDefault();
1476
+ e.stopPropagation();
1477
+ {
1478
+ const track = this.tracks[index];
1479
+ const isExternalRenderer = this.isExternalRendererUrl(track?.src);
1480
+
1481
+ // Exit fullscreen for external renderer tracks (YouTube, Vimeo, SoundCloud)
1482
+ if (isExternalRenderer && this.player.state.fullscreen) {
1483
+ this.player.exitFullscreen();
1484
+ setTimeout(() => {
1485
+ this.play(index, true);
1486
+ }, 100);
1487
+ } else {
1488
+ this.play(index, true); // User-initiated
1489
+ }
1490
+ }
1491
+ return; // No need to move focus
1492
+
1493
+ case 'ArrowDown':
1494
+ e.preventDefault();
1495
+ e.stopPropagation();
1496
+ // Move to next item
1497
+ if (index < buttons.length - 1) {
1498
+ newIndex = index + 1;
1499
+ } else {
1500
+ // At the end, announce boundary
1501
+ announcement = i18n.t('playlist.endOfPlaylist', { current: buttons.length, total: buttons.length });
1502
+ }
1503
+ break;
1504
+
1505
+ case 'ArrowUp':
1506
+ e.preventDefault();
1507
+ e.stopPropagation();
1508
+ // Move to previous item
1509
+ if (index > 0) {
1510
+ newIndex = index - 1;
1511
+ } else {
1512
+ // At the beginning, announce boundary
1513
+ announcement = i18n.t('playlist.beginningOfPlaylist', { total: buttons.length });
1514
+ }
1515
+ break;
1516
+
1517
+ case 'PageDown':
1518
+ e.preventDefault();
1519
+ e.stopPropagation();
1520
+ // Move 5 items down (or to end)
1521
+ newIndex = Math.min(index + 5, buttons.length - 1);
1522
+ if (newIndex === buttons.length - 1 && index !== newIndex) {
1523
+ announcement = i18n.t('playlist.jumpedToLastTrack', { current: newIndex + 1, total: buttons.length });
1524
+ }
1525
+ break;
1526
+
1527
+ case 'PageUp':
1528
+ e.preventDefault();
1529
+ e.stopPropagation();
1530
+ // Move 5 items up (or to beginning)
1531
+ newIndex = Math.max(index - 5, 0);
1532
+ if (newIndex === 0 && index !== newIndex) {
1533
+ announcement = i18n.t('playlist.jumpedToFirstTrack', { total: buttons.length });
1534
+ }
1535
+ break;
1536
+
1537
+ case 'Home':
1538
+ e.preventDefault();
1539
+ e.stopPropagation();
1540
+ // Move to first item
1541
+ newIndex = 0;
1542
+ if (index !== 0) {
1543
+ announcement = i18n.t('playlist.firstTrack', { total: buttons.length });
1544
+ }
1545
+ break;
1546
+
1547
+ case 'End':
1548
+ e.preventDefault();
1549
+ e.stopPropagation();
1550
+ // Move to last item
1551
+ newIndex = buttons.length - 1;
1552
+ if (index !== buttons.length - 1) {
1553
+ announcement = i18n.t('playlist.lastTrack', { current: buttons.length, total: buttons.length });
1554
+ }
1555
+ break;
1556
+ }
1557
+
1558
+ // Update tab indices for roving tabindex pattern
1559
+ if (newIndex !== -1 && newIndex !== index) {
1560
+ const currentButton = buttons[index];
1561
+ const newButton = buttons[newIndex];
1562
+ if (currentButton && newButton) {
1563
+ currentButton.setAttribute('tabIndex', '-1');
1564
+ newButton.setAttribute('tabIndex', '0');
1565
+ newButton.focus({ preventScroll: false });
1566
+
1567
+ // Scroll the focused item into view (same behavior as mouse interaction)
1568
+ const item = newButton.closest('.vidply-playlist-item');
1569
+ if (item) {
1570
+ item.scrollIntoView(reducedMotionScrollOptions('nearest'));
1571
+ }
1572
+ }
1573
+ }
1574
+
1575
+ // Announce navigation feedback
1576
+ if (announcement && this.navigationFeedback) {
1577
+ this.navigationFeedback.textContent = announcement;
1578
+ // Clear after a short delay to allow for repeated announcements
1579
+ setTimeout(() => {
1580
+ if (this.navigationFeedback) {
1581
+ this.navigationFeedback.textContent = '';
1582
+ }
1583
+ }, 1000);
1584
+ }
1585
+ }
1586
+
1587
+ /**
1588
+ * Update playlist UI (highlight current track)
1589
+ */
1590
+ updatePlaylistUI() {
1591
+ if (!this.playlistPanel) return;
1592
+
1593
+ const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
1594
+ const buttons = this.playlistPanel.querySelectorAll('.vidply-playlist-item-button');
1595
+
1596
+ items.forEach((item: Element, index: number) => {
1597
+ const button = buttons[index];
1598
+ if (!button) return;
1599
+
1600
+ const track = this.tracks[index];
1601
+ if (!track) return;
1602
+ const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
1603
+ const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
1604
+
1605
+ // Use effective duration (audio description duration when AD is enabled)
1606
+ const effectiveDuration = this.getEffectiveDuration(track);
1607
+ const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
1608
+
1609
+ if (index === this.currentIndex) {
1610
+ // Update list item styling
1611
+ item.classList.add('vidply-playlist-item-active');
1612
+
1613
+ // Update button ARIA attributes
1614
+ button.setAttribute('aria-current', 'true');
1615
+ button.setAttribute('aria-checked', 'true');
1616
+ button.setAttribute('tabIndex', '0'); // Active item should be tabbable
1617
+
1618
+ // Simplified aria-label - status and actions are announced via ARIA roles
1619
+ let ariaLabel = `${trackTitle}${trackArtist}`;
1620
+ if (trackDurationReadable) {
1621
+ ariaLabel += `. ${trackDurationReadable}`;
1622
+ }
1623
+ button.setAttribute('aria-label', ariaLabel);
1624
+
1625
+ // Scroll into view within playlist panel (uses 'nearest' to minimize page scroll)
1626
+ item.scrollIntoView(reducedMotionScrollOptions('nearest'));
1627
+ } else {
1628
+ // Update list item styling
1629
+ item.classList.remove('vidply-playlist-item-active');
1630
+
1631
+ // Update button ARIA attributes
1632
+ button.removeAttribute('aria-current');
1633
+ button.setAttribute('aria-checked', 'false');
1634
+ button.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
1635
+
1636
+ // Simplified aria-label - status and actions are announced via ARIA roles
1637
+ let ariaLabel = `${trackTitle}${trackArtist}`;
1638
+ if (trackDurationReadable) {
1639
+ ariaLabel += `. ${trackDurationReadable}`;
1640
+ }
1641
+ button.setAttribute('aria-label', ariaLabel);
1642
+ }
1643
+ });
1644
+ }
1645
+
1646
+ /**
1647
+ * Get current track
1648
+ */
1649
+ getCurrentTrack() {
1650
+ return this.tracks[this.currentIndex] || null;
1651
+ }
1652
+
1653
+ /**
1654
+ * Get playlist info
1655
+ */
1656
+ getPlaylistInfo() {
1657
+ return {
1658
+ currentIndex: this.currentIndex,
1659
+ totalTracks: this.tracks.length,
1660
+ currentTrack: this.getCurrentTrack(),
1661
+ hasNext: this.hasNext(),
1662
+ hasPrevious: this.hasPrevious()
1663
+ };
1664
+ }
1665
+
1666
+ /**
1667
+ * Check if there is a next track
1668
+ */
1669
+ hasNext() {
1670
+ if (this.options.loop) return true;
1671
+ return this.currentIndex < this.tracks.length - 1;
1672
+ }
1673
+
1674
+ /**
1675
+ * Check if there is a previous track
1676
+ */
1677
+ hasPrevious() {
1678
+ if (this.options.loop) return true;
1679
+ return this.currentIndex > 0;
1680
+ }
1681
+
1682
+ /**
1683
+ * Add track to playlist
1684
+ */
1685
+ addTrack(track: PlaylistTrack) {
1686
+ this.tracks.push(track);
1687
+
1688
+ if (this.playlistPanel) {
1689
+ this.renderPlaylist();
1690
+ }
1691
+ }
1692
+
1693
+ /**
1694
+ * Remove track from playlist
1695
+ */
1696
+ removeTrack(index: number) {
1697
+ if (index < 0 || index >= this.tracks.length) return;
1698
+
1699
+ this.tracks.splice(index, 1);
1700
+
1701
+ // Adjust current index if needed
1702
+ if (index < this.currentIndex) {
1703
+ this.currentIndex--;
1704
+ } else if (index === this.currentIndex) {
1705
+ // Current track was removed, play next or stop
1706
+ if (this.currentIndex >= this.tracks.length) {
1707
+ this.currentIndex = this.tracks.length - 1;
1708
+ }
1709
+
1710
+ if (this.currentIndex >= 0) {
1711
+ this.play(this.currentIndex);
1712
+ }
1713
+ }
1714
+
1715
+ if (this.playlistPanel) {
1716
+ this.renderPlaylist();
1717
+ }
1718
+ }
1719
+
1720
+ /**
1721
+ * Clear playlist
1722
+ */
1723
+ clear() {
1724
+ this.tracks = [];
1725
+ this.currentIndex = -1;
1726
+
1727
+ if (this.playlistPanel) {
1728
+ this.playlistPanel.innerHTML = '';
1729
+ this.playlistPanel.style.display = 'none';
1730
+ }
1731
+
1732
+ if (this.trackInfoElement) {
1733
+ this.trackInfoElement.innerHTML = '';
1734
+ this.trackInfoElement.style.display = 'none';
1735
+ }
1736
+
1737
+ if (this.trackArtworkElement) {
1738
+ this.trackArtworkElement.style.backgroundImage = '';
1739
+ this.trackArtworkElement.style.display = 'none';
1740
+ }
1741
+ }
1742
+
1743
+ /**
1744
+ * Toggle playlist panel visibility
1745
+ * @param {boolean} show - Optional: force show (true) or hide (false)
1746
+ * @returns {boolean} - New visibility state
1747
+ */
1748
+ togglePanel(show?: boolean) {
1749
+ const playlistPanel = this.playlistPanel;
1750
+ if (!playlistPanel) return false;
1751
+
1752
+ // Determine new state
1753
+ const shouldShow = show !== undefined ? show : playlistPanel.style.display === 'none';
1754
+
1755
+ if (shouldShow) {
1756
+ playlistPanel.style.display = 'block';
1757
+ this.isPanelVisible = true;
1758
+
1759
+ // Focus first item if playlist has tracks
1760
+ if (this.tracks.length > 0) {
1761
+ setTimeout(() => {
1762
+ const firstItem = playlistPanel.querySelector<HTMLElement>('.vidply-playlist-item[tabindex="0"]');
1763
+ if (firstItem) {
1764
+ firstItem.focus({ preventScroll: true });
1765
+ }
1766
+ }, 100);
1767
+ }
1768
+
1769
+ // Update toggle button state if it exists
1770
+ if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1771
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'true');
1772
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'true');
1773
+ }
1774
+ } else {
1775
+ playlistPanel.style.display = 'none';
1776
+ this.isPanelVisible = false;
1777
+
1778
+ // Update toggle button state if it exists
1779
+ if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
1780
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'false');
1781
+ this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'false');
1782
+
1783
+ // Return focus to toggle button
1784
+ this.player.controlBar.controls.playlistToggle.focus({ preventScroll: true });
1785
+ }
1786
+ }
1787
+
1788
+ return this.isPanelVisible;
1789
+ }
1790
+
1791
+ /**
1792
+ * Show playlist panel
1793
+ */
1794
+ showPanel() {
1795
+ return this.togglePanel(true);
1796
+ }
1797
+
1798
+ /**
1799
+ * Hide playlist panel
1800
+ */
1801
+ hidePanel() {
1802
+ return this.togglePanel(false);
1803
+ }
1804
+
1805
+ /**
1806
+ * Destroy playlist manager
1807
+ */
1808
+ destroy() {
1809
+ // Remove every listener registered in init(). All handlers are pre-bound
1810
+ // in the constructor, so these references match what on() registered.
1811
+ this.player.off('ended', this.handleTrackEnd);
1812
+ this.player.off('error', this.handleTrackError);
1813
+ this.player.off('play', this.handlePlaybackStateChange);
1814
+ this.player.off('pause', this.handlePlaybackStateChange);
1815
+ this.player.off('ended', this.handlePlaybackStateChange);
1816
+ this.player.off('fullscreenchange', this.handleFullscreenChange);
1817
+ this.player.off('audiodescriptionenabled', this.handleAudioDescriptionChange);
1818
+ this.player.off('audiodescriptiondisabled', this.handleAudioDescriptionChange);
1819
+
1820
+ // Remove UI
1821
+ if (this.trackArtworkElement) {
1822
+ this.trackArtworkElement.remove();
1823
+ }
1824
+
1825
+ if (this.trackInfoElement) {
1826
+ this.trackInfoElement.remove();
1827
+ }
1828
+
1829
+ if (this.playlistPanel) {
1830
+ this.playlistPanel.remove();
1831
+ }
1832
+
1833
+ // Clear data
1834
+ this.clear();
1835
+ }
1836
+ }
1837
+
1838
+ export default PlaylistManager;