vidply 1.2.0 → 1.2.1

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 (164) hide show
  1. package/README.md +946 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
  23. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
  24. package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
  25. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
  26. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
  27. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
  28. package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
  30. package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
  32. package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
  34. package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
  35. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
  36. package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
  37. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
  38. package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
  39. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
  41. package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
  43. package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +73 -34
  45. package/dist/dev/vidply.esm.js.map +2 -2
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
  47. package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
  49. package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
  50. package/dist/legacy/vidply.js +345 -32
  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 +66 -47
  54. package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
  66. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
  67. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
  69. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
  72. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
  75. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
  81. package/dist/vidply.css +5796 -5796
  82. package/dist/vidply.esm.min.meta.json +148 -135
  83. package/package.json +7 -7
  84. package/src/controls/CaptionManager.ts +598 -598
  85. package/src/controls/ControlBar.ts +4159 -4159
  86. package/src/controls/KeyboardManager.ts +383 -383
  87. package/src/controls/SettingsDialog.ts +488 -488
  88. package/src/controls/TranscriptManager.ts +2426 -2426
  89. package/src/core/AudioDescriptionManager.ts +863 -774
  90. package/src/core/DescriptionSpeechManager.ts +232 -0
  91. package/src/core/FloatingPlayerManager.ts +674 -674
  92. package/src/core/LazyInit.ts +80 -80
  93. package/src/core/MetadataAlertsManager.ts +514 -514
  94. package/src/core/Player.ts +2915 -2872
  95. package/src/core/PosterManager.ts +169 -169
  96. package/src/core/PseudoFullscreen.ts +179 -179
  97. package/src/core/ResponsiveManager.ts +231 -231
  98. package/src/core/ResumeManager.ts +236 -236
  99. package/src/core/SignLanguageManager.ts +1308 -1308
  100. package/src/core/ThemeManager.ts +157 -157
  101. package/src/features/PlaylistManager.ts +1838 -1827
  102. package/src/i18n/i18n.ts +223 -223
  103. package/src/i18n/languages/de.ts +217 -217
  104. package/src/i18n/languages/en.ts +218 -218
  105. package/src/i18n/languages/es.ts +214 -214
  106. package/src/i18n/languages/fr.ts +214 -214
  107. package/src/i18n/languages/ja.ts +214 -214
  108. package/src/icons/Icons.ts +167 -167
  109. package/src/index.ts +196 -193
  110. package/src/renderers/DASHRenderer.ts +1077 -1077
  111. package/src/renderers/HLSRenderer.ts +944 -944
  112. package/src/renderers/HTML5Renderer.ts +389 -389
  113. package/src/renderers/SoundCloudRenderer.ts +437 -437
  114. package/src/renderers/VimeoRenderer.ts +298 -298
  115. package/src/renderers/YouTubeRenderer.ts +290 -290
  116. package/src/styles/vidply.css +5796 -5796
  117. package/src/types/events.ts +124 -122
  118. package/src/types/globals.d.ts +313 -313
  119. package/src/types/options.ts +185 -182
  120. package/src/types/renderer.ts +58 -58
  121. package/src/utils/DOMUtils.ts +281 -281
  122. package/src/utils/DownloadInfo.ts +171 -171
  123. package/src/utils/DraggablePanel.ts +539 -539
  124. package/src/utils/DraggablePanelMenu.ts +195 -195
  125. package/src/utils/DraggableResizable.ts +997 -997
  126. package/src/utils/EventEmitter.ts +59 -59
  127. package/src/utils/FormUtils.ts +95 -95
  128. package/src/utils/MenuFactory.ts +483 -483
  129. package/src/utils/Sanitize.ts +70 -70
  130. package/src/utils/SettingsMenuFactory.ts +289 -289
  131. package/src/utils/StorageManager.ts +287 -287
  132. package/src/utils/TrackLabelUtils.ts +52 -52
  133. package/src/utils/UrlSafe.ts +69 -69
  134. package/src/utils/VideoFrameCapture.ts +114 -114
  135. package/src/utils/WindowComponents.ts +250 -250
  136. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  137. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  138. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  139. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  140. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  141. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  142. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  143. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  144. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  145. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  146. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  147. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  148. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  149. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  151. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  152. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  153. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  154. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  155. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  156. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  157. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  158. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  159. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
  160. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
  161. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
  162. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
  163. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.js.map} +0 -0
@@ -1,483 +1,483 @@
1
- /**
2
- * Menu Factory - Centralized menu creation and management
3
- * Reduces code duplication across ControlBar menu methods
4
- */
5
-
6
- import { DOMUtils } from './DOMUtils.js';
7
- import { createIconElement } from '../icons/Icons.js';
8
- import { i18n } from '../i18n/i18n.js';
9
- import { attachMenuKeyboardNavigation } from './MenuUtils.js';
10
- import type { Player } from '../core/Player.js';
11
-
12
- interface DividerMenuItem {
13
- type: 'divider';
14
- }
15
-
16
- interface HeaderMenuItem {
17
- type: 'header';
18
- text: string;
19
- }
20
-
21
- interface ActionMenuItem {
22
- type?: 'action';
23
- text?: string;
24
- value?: unknown;
25
- active?: boolean;
26
- disabled?: boolean;
27
- icon?: string;
28
- textClass?: string;
29
- timeLabel?: string;
30
- timeAriaLabel?: string;
31
- onClick?: (value: unknown, index: number) => void;
32
- }
33
-
34
- type MenuItem = DividerMenuItem | HeaderMenuItem | ActionMenuItem;
35
-
36
- interface CreateMenuOptions {
37
- player: Player;
38
- button: HTMLElement;
39
- menuClass: string;
40
- ariaLabel: string;
41
- items?: MenuItem[];
42
- activeIndex?: number;
43
- onClose?: (() => void) | null;
44
- // When true, actionable items represent a single-select group and use
45
- // role="menuitemradio" + aria-checked so assistive tech announces the
46
- // current selection, not just a visual check icon (WCAG 4.1.2).
47
- radioGroup?: boolean;
48
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
49
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
50
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
51
- }
52
-
53
- interface SpeedMenuOptions {
54
- player: Player;
55
- button: HTMLElement;
56
- currentSpeed: number;
57
- speeds?: number[];
58
- onSpeedChange: (value: number) => void;
59
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
60
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
61
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
62
- }
63
-
64
- interface CaptionsMenuOptions {
65
- player: Player;
66
- button: HTMLElement;
67
- tracks: Array<{ label?: string; language?: string }>;
68
- currentTrackIndex: number;
69
- captionsEnabled: boolean;
70
- onTrackSelect: (index: number) => void;
71
- onDisable: () => void;
72
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
73
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
74
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
75
- }
76
-
77
- interface ChaptersMenuOptions {
78
- player: Player;
79
- button: HTMLElement;
80
- chapters: Array<{ startTime: number; text: string }> | null | undefined;
81
- onChapterSelect: (value: number) => void;
82
- formatTime: (seconds: number) => string;
83
- formatDuration: (seconds: number) => string;
84
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
85
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
86
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
87
- }
88
-
89
- interface QualityMenuOptions {
90
- player: Player;
91
- button: HTMLElement;
92
- qualities: Array<{ name?: string; height?: number; index: number }>;
93
- currentQuality: number;
94
- isHLS: boolean;
95
- onQualitySelect: (index: number) => void;
96
- insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
97
- positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
98
- attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
99
- }
100
-
101
- /**
102
- * Create and show a menu with standard positioning and behavior
103
- * @param {Object} options - Menu configuration
104
- * @returns {HTMLElement} The created menu element
105
- */
106
- export function createMenu({
107
- player,
108
- button,
109
- menuClass,
110
- ariaLabel,
111
- items = [],
112
- activeIndex = -1,
113
- onClose = null,
114
- radioGroup = false,
115
- insertIntoDOM = null,
116
- positionMenu = null,
117
- attachCloseHandler = null
118
- }: CreateMenuOptions): HTMLElement | null {
119
- const classPrefix = player.options.classPrefix;
120
-
121
- // Remove existing menu (toggle behavior)
122
- const existingMenu = document.querySelector(`.${classPrefix}-${menuClass}`);
123
- if (existingMenu) {
124
- existingMenu.remove();
125
- button.setAttribute('aria-expanded', 'false');
126
- return null;
127
- }
128
-
129
- // Create menu container
130
- const menu = DOMUtils.createElement('div', {
131
- className: `${classPrefix}-${menuClass} ${classPrefix}-menu`,
132
- attributes: {
133
- 'role': 'menu',
134
- 'aria-label': ariaLabel
135
- }
136
- });
137
-
138
- let activeItem: HTMLElement | null = null;
139
-
140
- // Create menu items
141
- items.forEach((itemConfig, index) => {
142
- if (itemConfig.type === 'divider') {
143
- const divider = DOMUtils.createElement('div', {
144
- className: `${classPrefix}-menu-divider`,
145
- attributes: { 'role': 'separator' }
146
- });
147
- menu.appendChild(divider);
148
- return;
149
- }
150
-
151
- if (itemConfig.type === 'header') {
152
- const header = DOMUtils.createElement('div', {
153
- className: `${classPrefix}-menu-header`,
154
- textContent: itemConfig.text
155
- });
156
- menu.appendChild(header);
157
- return;
158
- }
159
-
160
- if ('disabled' in itemConfig && itemConfig.disabled) {
161
- // Empty-state text ("No chapters", "Auto quality"). It is not
162
- // actionable, so expose it as aria-disabled and keep it out of the
163
- // roving keyboard navigation (see attachMenuKeyboardNavigation),
164
- // which only steps through enabled menuitems (WCAG 2.1.1, 4.1.2).
165
- const disabledItem = DOMUtils.createElement('div', {
166
- className: `${classPrefix}-menu-item ${classPrefix}-menu-item-disabled`,
167
- textContent: itemConfig.text,
168
- attributes: {
169
- 'role': 'menuitem',
170
- 'aria-disabled': 'true',
171
- 'tabindex': '-1'
172
- },
173
- style: { opacity: '0.5', cursor: 'default' }
174
- });
175
- menu.appendChild(disabledItem);
176
- return;
177
- }
178
-
179
- // Add content based on item type
180
- const actionItem = itemConfig as ActionMenuItem;
181
- const isActive = actionItem.active || index === activeIndex;
182
-
183
- const itemAttributes: Record<string, string> = {
184
- 'type': 'button',
185
- 'role': radioGroup ? 'menuitemradio' : 'menuitem',
186
- 'tabindex': '-1'
187
- };
188
- if (radioGroup) {
189
- itemAttributes['aria-checked'] = isActive ? 'true' : 'false';
190
- }
191
-
192
- const item = DOMUtils.createElement('button', {
193
- className: `${classPrefix}-menu-item`,
194
- attributes: itemAttributes
195
- });
196
-
197
- if (actionItem.icon) {
198
- item.appendChild(createIconElement(actionItem.icon));
199
- }
200
-
201
- if (actionItem.timeLabel) {
202
- const timeSpan = DOMUtils.createElement('span', {
203
- className: `${classPrefix}-chapter-time`,
204
- textContent: actionItem.timeLabel,
205
- attributes: actionItem.timeAriaLabel
206
- ? { 'aria-label': actionItem.timeAriaLabel }
207
- : {}
208
- });
209
- item.appendChild(timeSpan);
210
- item.appendChild(document.createTextNode(' '));
211
- }
212
-
213
- if (actionItem.text) {
214
- const textSpan = DOMUtils.createElement('span', {
215
- className: actionItem.textClass || `${classPrefix}-menu-item-text`,
216
- textContent: actionItem.text
217
- });
218
- item.appendChild(textSpan);
219
- }
220
-
221
- // Mark as active
222
- if (isActive) {
223
- item.classList.add(`${classPrefix}-menu-item-active`);
224
- item.appendChild(createIconElement('check'));
225
- activeItem = item;
226
- }
227
-
228
- // Click handler
229
- const onClick = actionItem.onClick;
230
- if (onClick) {
231
- item.addEventListener('click', () => {
232
- onClick(actionItem.value, index);
233
- closeMenuAndReturnFocus(menu, button, onClose);
234
- });
235
- }
236
-
237
- menu.appendChild(item);
238
- });
239
-
240
- // Position menu (hide first to prevent jumping)
241
- menu.style.visibility = 'hidden';
242
- menu.style.display = 'block';
243
-
244
- // Insert into DOM
245
- if (insertIntoDOM) {
246
- insertIntoDOM(menu, button);
247
- } else {
248
- button.insertAdjacentElement('afterend', menu);
249
- }
250
-
251
- // Position
252
- if (positionMenu) {
253
- positionMenu(menu, button, true);
254
- }
255
-
256
- // Show menu
257
- requestAnimationFrame(() => {
258
- menu.style.visibility = 'visible';
259
- });
260
-
261
- // Add keyboard navigation
262
- attachMenuKeyboardNavigation(menu, button, `.${classPrefix}-menu-item`, () => {
263
- closeMenuAndReturnFocus(menu, button, onClose);
264
- });
265
-
266
- // Focus active or first enabled item (never an aria-disabled empty state).
267
- setTimeout(() => {
268
- const focusTarget = activeItem
269
- || menu.querySelector(`.${classPrefix}-menu-item:not([aria-disabled="true"])`);
270
- if (focusTarget) {
271
- (focusTarget as HTMLElement).focus({ preventScroll: true });
272
- }
273
- }, 0);
274
-
275
- // Attach close handler
276
- if (attachCloseHandler) {
277
- attachCloseHandler(menu, button);
278
- }
279
-
280
- button.setAttribute('aria-expanded', 'true');
281
-
282
- return menu;
283
- }
284
-
285
- /**
286
- * Close menu and return focus to button
287
- */
288
- function closeMenuAndReturnFocus(menu: HTMLElement | null, button: HTMLElement, onClose: (() => void) | null = null): void {
289
- if (menu) {
290
- menu.remove();
291
- }
292
- button.setAttribute('aria-expanded', 'false');
293
- button.focus({ preventScroll: true });
294
- if (onClose) {
295
- onClose();
296
- }
297
- }
298
-
299
- /**
300
- * Create a speed menu
301
- */
302
- export function createSpeedMenu({
303
- player,
304
- button,
305
- currentSpeed,
306
- speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
307
- onSpeedChange,
308
- insertIntoDOM,
309
- positionMenu,
310
- attachCloseHandler
311
- }: SpeedMenuOptions): HTMLElement | null {
312
- const items = speeds.map(speed => ({
313
- text: speed === 1 ? i18n.t('player.normalSpeed') : `${speed}x`,
314
- value: speed,
315
- active: Math.abs(currentSpeed - speed) < 0.01,
316
- onClick: (value: unknown) => onSpeedChange(value as number)
317
- }));
318
-
319
- return createMenu({
320
- player,
321
- button,
322
- menuClass: 'speed-menu',
323
- ariaLabel: i18n.t('player.speed'),
324
- items,
325
- radioGroup: true,
326
- insertIntoDOM,
327
- positionMenu,
328
- attachCloseHandler
329
- });
330
- }
331
-
332
- /**
333
- * Create a captions menu
334
- */
335
- export function createCaptionsMenu({
336
- player,
337
- button,
338
- tracks,
339
- currentTrackIndex,
340
- captionsEnabled,
341
- onTrackSelect,
342
- onDisable,
343
- insertIntoDOM,
344
- positionMenu,
345
- attachCloseHandler
346
- }: CaptionsMenuOptions): HTMLElement | null {
347
- const items: Array<{text: string; value?: number; active: boolean; onClick: () => void}> = [
348
- {
349
- text: i18n.t('player.captionsOff'),
350
- active: !captionsEnabled,
351
- onClick: () => onDisable()
352
- }
353
- ];
354
-
355
- tracks.forEach((track, index) => {
356
- items.push({
357
- text: track.label || track.language || '',
358
- value: index,
359
- active: captionsEnabled && currentTrackIndex === index,
360
- onClick: () => onTrackSelect(index)
361
- });
362
- });
363
-
364
- return createMenu({
365
- player,
366
- button,
367
- menuClass: 'captions-menu',
368
- ariaLabel: i18n.t('player.captions'),
369
- items,
370
- radioGroup: true,
371
- insertIntoDOM,
372
- positionMenu,
373
- attachCloseHandler
374
- });
375
- }
376
-
377
- /**
378
- * Create a chapters menu
379
- */
380
- export function createChaptersMenu({
381
- player,
382
- button,
383
- chapters,
384
- onChapterSelect,
385
- formatTime,
386
- formatDuration,
387
- insertIntoDOM,
388
- positionMenu,
389
- attachCloseHandler
390
- }: ChaptersMenuOptions): HTMLElement | null {
391
- if (!chapters || chapters.length === 0) {
392
- return createMenu({
393
- player,
394
- button,
395
- menuClass: 'chapters-menu',
396
- ariaLabel: i18n.t('player.chapters'),
397
- items: [{
398
- text: i18n.t('player.noChapters'),
399
- disabled: true
400
- }],
401
- insertIntoDOM,
402
- positionMenu,
403
- attachCloseHandler
404
- });
405
- }
406
-
407
- const items = chapters.map(chapter => ({
408
- timeLabel: formatTime(chapter.startTime),
409
- timeAriaLabel: formatDuration(chapter.startTime),
410
- text: chapter.text,
411
- textClass: `${player.options.classPrefix}-chapter-title`,
412
- value: chapter.startTime,
413
- onClick: (value: unknown) => onChapterSelect(value as number)
414
- }));
415
-
416
- return createMenu({
417
- player,
418
- button,
419
- menuClass: 'chapters-menu',
420
- ariaLabel: i18n.t('player.chapters'),
421
- items,
422
- insertIntoDOM,
423
- positionMenu,
424
- attachCloseHandler
425
- });
426
- }
427
-
428
- /**
429
- * Create a quality menu
430
- */
431
- export function createQualityMenu({
432
- player,
433
- button,
434
- qualities,
435
- currentQuality,
436
- isHLS,
437
- onQualitySelect,
438
- insertIntoDOM,
439
- positionMenu,
440
- attachCloseHandler
441
- }: QualityMenuOptions): HTMLElement | null {
442
- const items: ActionMenuItem[] = [];
443
-
444
- // Auto option for HLS
445
- if (isHLS) {
446
- items.push({
447
- text: i18n.t('player.auto'),
448
- value: -1,
449
- active: currentQuality === -1,
450
- onClick: () => onQualitySelect(-1)
451
- });
452
- }
453
-
454
- // Quality options
455
- qualities.forEach(quality => {
456
- items.push({
457
- text: quality.name || `${quality.height}p`,
458
- value: quality.index,
459
- active: quality.index === currentQuality,
460
- onClick: () => onQualitySelect(quality.index)
461
- });
462
- });
463
-
464
- if (items.length === 0) {
465
- items.push({
466
- text: i18n.t('player.autoQuality'),
467
- disabled: true
468
- });
469
- }
470
-
471
- return createMenu({
472
- player,
473
- button,
474
- menuClass: 'quality-menu',
475
- ariaLabel: i18n.t('player.quality'),
476
- items,
477
- radioGroup: true,
478
- insertIntoDOM,
479
- positionMenu,
480
- attachCloseHandler
481
- });
482
- }
483
-
1
+ /**
2
+ * Menu Factory - Centralized menu creation and management
3
+ * Reduces code duplication across ControlBar menu methods
4
+ */
5
+
6
+ import { DOMUtils } from './DOMUtils.js';
7
+ import { createIconElement } from '../icons/Icons.js';
8
+ import { i18n } from '../i18n/i18n.js';
9
+ import { attachMenuKeyboardNavigation } from './MenuUtils.js';
10
+ import type { Player } from '../core/Player.js';
11
+
12
+ interface DividerMenuItem {
13
+ type: 'divider';
14
+ }
15
+
16
+ interface HeaderMenuItem {
17
+ type: 'header';
18
+ text: string;
19
+ }
20
+
21
+ interface ActionMenuItem {
22
+ type?: 'action';
23
+ text?: string;
24
+ value?: unknown;
25
+ active?: boolean;
26
+ disabled?: boolean;
27
+ icon?: string;
28
+ textClass?: string;
29
+ timeLabel?: string;
30
+ timeAriaLabel?: string;
31
+ onClick?: (value: unknown, index: number) => void;
32
+ }
33
+
34
+ type MenuItem = DividerMenuItem | HeaderMenuItem | ActionMenuItem;
35
+
36
+ interface CreateMenuOptions {
37
+ player: Player;
38
+ button: HTMLElement;
39
+ menuClass: string;
40
+ ariaLabel: string;
41
+ items?: MenuItem[];
42
+ activeIndex?: number;
43
+ onClose?: (() => void) | null;
44
+ // When true, actionable items represent a single-select group and use
45
+ // role="menuitemradio" + aria-checked so assistive tech announces the
46
+ // current selection, not just a visual check icon (WCAG 4.1.2).
47
+ radioGroup?: boolean;
48
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
49
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
50
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
51
+ }
52
+
53
+ interface SpeedMenuOptions {
54
+ player: Player;
55
+ button: HTMLElement;
56
+ currentSpeed: number;
57
+ speeds?: number[];
58
+ onSpeedChange: (value: number) => void;
59
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
60
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
61
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
62
+ }
63
+
64
+ interface CaptionsMenuOptions {
65
+ player: Player;
66
+ button: HTMLElement;
67
+ tracks: Array<{ label?: string; language?: string }>;
68
+ currentTrackIndex: number;
69
+ captionsEnabled: boolean;
70
+ onTrackSelect: (index: number) => void;
71
+ onDisable: () => void;
72
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
73
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
74
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
75
+ }
76
+
77
+ interface ChaptersMenuOptions {
78
+ player: Player;
79
+ button: HTMLElement;
80
+ chapters: Array<{ startTime: number; text: string }> | null | undefined;
81
+ onChapterSelect: (value: number) => void;
82
+ formatTime: (seconds: number) => string;
83
+ formatDuration: (seconds: number) => string;
84
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
85
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
86
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
87
+ }
88
+
89
+ interface QualityMenuOptions {
90
+ player: Player;
91
+ button: HTMLElement;
92
+ qualities: Array<{ name?: string; height?: number; index: number }>;
93
+ currentQuality: number;
94
+ isHLS: boolean;
95
+ onQualitySelect: (index: number) => void;
96
+ insertIntoDOM?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
97
+ positionMenu?: ((menu: HTMLElement, button: HTMLElement, initial?: boolean) => void) | null;
98
+ attachCloseHandler?: ((menu: HTMLElement, button: HTMLElement) => void) | null;
99
+ }
100
+
101
+ /**
102
+ * Create and show a menu with standard positioning and behavior
103
+ * @param {Object} options - Menu configuration
104
+ * @returns {HTMLElement} The created menu element
105
+ */
106
+ export function createMenu({
107
+ player,
108
+ button,
109
+ menuClass,
110
+ ariaLabel,
111
+ items = [],
112
+ activeIndex = -1,
113
+ onClose = null,
114
+ radioGroup = false,
115
+ insertIntoDOM = null,
116
+ positionMenu = null,
117
+ attachCloseHandler = null
118
+ }: CreateMenuOptions): HTMLElement | null {
119
+ const classPrefix = player.options.classPrefix;
120
+
121
+ // Remove existing menu (toggle behavior)
122
+ const existingMenu = document.querySelector(`.${classPrefix}-${menuClass}`);
123
+ if (existingMenu) {
124
+ existingMenu.remove();
125
+ button.setAttribute('aria-expanded', 'false');
126
+ return null;
127
+ }
128
+
129
+ // Create menu container
130
+ const menu = DOMUtils.createElement('div', {
131
+ className: `${classPrefix}-${menuClass} ${classPrefix}-menu`,
132
+ attributes: {
133
+ 'role': 'menu',
134
+ 'aria-label': ariaLabel
135
+ }
136
+ });
137
+
138
+ let activeItem: HTMLElement | null = null;
139
+
140
+ // Create menu items
141
+ items.forEach((itemConfig, index) => {
142
+ if (itemConfig.type === 'divider') {
143
+ const divider = DOMUtils.createElement('div', {
144
+ className: `${classPrefix}-menu-divider`,
145
+ attributes: { 'role': 'separator' }
146
+ });
147
+ menu.appendChild(divider);
148
+ return;
149
+ }
150
+
151
+ if (itemConfig.type === 'header') {
152
+ const header = DOMUtils.createElement('div', {
153
+ className: `${classPrefix}-menu-header`,
154
+ textContent: itemConfig.text
155
+ });
156
+ menu.appendChild(header);
157
+ return;
158
+ }
159
+
160
+ if ('disabled' in itemConfig && itemConfig.disabled) {
161
+ // Empty-state text ("No chapters", "Auto quality"). It is not
162
+ // actionable, so expose it as aria-disabled and keep it out of the
163
+ // roving keyboard navigation (see attachMenuKeyboardNavigation),
164
+ // which only steps through enabled menuitems (WCAG 2.1.1, 4.1.2).
165
+ const disabledItem = DOMUtils.createElement('div', {
166
+ className: `${classPrefix}-menu-item ${classPrefix}-menu-item-disabled`,
167
+ textContent: itemConfig.text,
168
+ attributes: {
169
+ 'role': 'menuitem',
170
+ 'aria-disabled': 'true',
171
+ 'tabindex': '-1'
172
+ },
173
+ style: { opacity: '0.5', cursor: 'default' }
174
+ });
175
+ menu.appendChild(disabledItem);
176
+ return;
177
+ }
178
+
179
+ // Add content based on item type
180
+ const actionItem = itemConfig as ActionMenuItem;
181
+ const isActive = actionItem.active || index === activeIndex;
182
+
183
+ const itemAttributes: Record<string, string> = {
184
+ 'type': 'button',
185
+ 'role': radioGroup ? 'menuitemradio' : 'menuitem',
186
+ 'tabindex': '-1'
187
+ };
188
+ if (radioGroup) {
189
+ itemAttributes['aria-checked'] = isActive ? 'true' : 'false';
190
+ }
191
+
192
+ const item = DOMUtils.createElement('button', {
193
+ className: `${classPrefix}-menu-item`,
194
+ attributes: itemAttributes
195
+ });
196
+
197
+ if (actionItem.icon) {
198
+ item.appendChild(createIconElement(actionItem.icon));
199
+ }
200
+
201
+ if (actionItem.timeLabel) {
202
+ const timeSpan = DOMUtils.createElement('span', {
203
+ className: `${classPrefix}-chapter-time`,
204
+ textContent: actionItem.timeLabel,
205
+ attributes: actionItem.timeAriaLabel
206
+ ? { 'aria-label': actionItem.timeAriaLabel }
207
+ : {}
208
+ });
209
+ item.appendChild(timeSpan);
210
+ item.appendChild(document.createTextNode(' '));
211
+ }
212
+
213
+ if (actionItem.text) {
214
+ const textSpan = DOMUtils.createElement('span', {
215
+ className: actionItem.textClass || `${classPrefix}-menu-item-text`,
216
+ textContent: actionItem.text
217
+ });
218
+ item.appendChild(textSpan);
219
+ }
220
+
221
+ // Mark as active
222
+ if (isActive) {
223
+ item.classList.add(`${classPrefix}-menu-item-active`);
224
+ item.appendChild(createIconElement('check'));
225
+ activeItem = item;
226
+ }
227
+
228
+ // Click handler
229
+ const onClick = actionItem.onClick;
230
+ if (onClick) {
231
+ item.addEventListener('click', () => {
232
+ onClick(actionItem.value, index);
233
+ closeMenuAndReturnFocus(menu, button, onClose);
234
+ });
235
+ }
236
+
237
+ menu.appendChild(item);
238
+ });
239
+
240
+ // Position menu (hide first to prevent jumping)
241
+ menu.style.visibility = 'hidden';
242
+ menu.style.display = 'block';
243
+
244
+ // Insert into DOM
245
+ if (insertIntoDOM) {
246
+ insertIntoDOM(menu, button);
247
+ } else {
248
+ button.insertAdjacentElement('afterend', menu);
249
+ }
250
+
251
+ // Position
252
+ if (positionMenu) {
253
+ positionMenu(menu, button, true);
254
+ }
255
+
256
+ // Show menu
257
+ requestAnimationFrame(() => {
258
+ menu.style.visibility = 'visible';
259
+ });
260
+
261
+ // Add keyboard navigation
262
+ attachMenuKeyboardNavigation(menu, button, `.${classPrefix}-menu-item`, () => {
263
+ closeMenuAndReturnFocus(menu, button, onClose);
264
+ });
265
+
266
+ // Focus active or first enabled item (never an aria-disabled empty state).
267
+ setTimeout(() => {
268
+ const focusTarget = activeItem
269
+ || menu.querySelector(`.${classPrefix}-menu-item:not([aria-disabled="true"])`);
270
+ if (focusTarget) {
271
+ (focusTarget as HTMLElement).focus({ preventScroll: true });
272
+ }
273
+ }, 0);
274
+
275
+ // Attach close handler
276
+ if (attachCloseHandler) {
277
+ attachCloseHandler(menu, button);
278
+ }
279
+
280
+ button.setAttribute('aria-expanded', 'true');
281
+
282
+ return menu;
283
+ }
284
+
285
+ /**
286
+ * Close menu and return focus to button
287
+ */
288
+ function closeMenuAndReturnFocus(menu: HTMLElement | null, button: HTMLElement, onClose: (() => void) | null = null): void {
289
+ if (menu) {
290
+ menu.remove();
291
+ }
292
+ button.setAttribute('aria-expanded', 'false');
293
+ button.focus({ preventScroll: true });
294
+ if (onClose) {
295
+ onClose();
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Create a speed menu
301
+ */
302
+ export function createSpeedMenu({
303
+ player,
304
+ button,
305
+ currentSpeed,
306
+ speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
307
+ onSpeedChange,
308
+ insertIntoDOM,
309
+ positionMenu,
310
+ attachCloseHandler
311
+ }: SpeedMenuOptions): HTMLElement | null {
312
+ const items = speeds.map(speed => ({
313
+ text: speed === 1 ? i18n.t('player.normalSpeed') : `${speed}x`,
314
+ value: speed,
315
+ active: Math.abs(currentSpeed - speed) < 0.01,
316
+ onClick: (value: unknown) => onSpeedChange(value as number)
317
+ }));
318
+
319
+ return createMenu({
320
+ player,
321
+ button,
322
+ menuClass: 'speed-menu',
323
+ ariaLabel: i18n.t('player.speed'),
324
+ items,
325
+ radioGroup: true,
326
+ insertIntoDOM,
327
+ positionMenu,
328
+ attachCloseHandler
329
+ });
330
+ }
331
+
332
+ /**
333
+ * Create a captions menu
334
+ */
335
+ export function createCaptionsMenu({
336
+ player,
337
+ button,
338
+ tracks,
339
+ currentTrackIndex,
340
+ captionsEnabled,
341
+ onTrackSelect,
342
+ onDisable,
343
+ insertIntoDOM,
344
+ positionMenu,
345
+ attachCloseHandler
346
+ }: CaptionsMenuOptions): HTMLElement | null {
347
+ const items: Array<{text: string; value?: number; active: boolean; onClick: () => void}> = [
348
+ {
349
+ text: i18n.t('player.captionsOff'),
350
+ active: !captionsEnabled,
351
+ onClick: () => onDisable()
352
+ }
353
+ ];
354
+
355
+ tracks.forEach((track, index) => {
356
+ items.push({
357
+ text: track.label || track.language || '',
358
+ value: index,
359
+ active: captionsEnabled && currentTrackIndex === index,
360
+ onClick: () => onTrackSelect(index)
361
+ });
362
+ });
363
+
364
+ return createMenu({
365
+ player,
366
+ button,
367
+ menuClass: 'captions-menu',
368
+ ariaLabel: i18n.t('player.captions'),
369
+ items,
370
+ radioGroup: true,
371
+ insertIntoDOM,
372
+ positionMenu,
373
+ attachCloseHandler
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Create a chapters menu
379
+ */
380
+ export function createChaptersMenu({
381
+ player,
382
+ button,
383
+ chapters,
384
+ onChapterSelect,
385
+ formatTime,
386
+ formatDuration,
387
+ insertIntoDOM,
388
+ positionMenu,
389
+ attachCloseHandler
390
+ }: ChaptersMenuOptions): HTMLElement | null {
391
+ if (!chapters || chapters.length === 0) {
392
+ return createMenu({
393
+ player,
394
+ button,
395
+ menuClass: 'chapters-menu',
396
+ ariaLabel: i18n.t('player.chapters'),
397
+ items: [{
398
+ text: i18n.t('player.noChapters'),
399
+ disabled: true
400
+ }],
401
+ insertIntoDOM,
402
+ positionMenu,
403
+ attachCloseHandler
404
+ });
405
+ }
406
+
407
+ const items = chapters.map(chapter => ({
408
+ timeLabel: formatTime(chapter.startTime),
409
+ timeAriaLabel: formatDuration(chapter.startTime),
410
+ text: chapter.text,
411
+ textClass: `${player.options.classPrefix}-chapter-title`,
412
+ value: chapter.startTime,
413
+ onClick: (value: unknown) => onChapterSelect(value as number)
414
+ }));
415
+
416
+ return createMenu({
417
+ player,
418
+ button,
419
+ menuClass: 'chapters-menu',
420
+ ariaLabel: i18n.t('player.chapters'),
421
+ items,
422
+ insertIntoDOM,
423
+ positionMenu,
424
+ attachCloseHandler
425
+ });
426
+ }
427
+
428
+ /**
429
+ * Create a quality menu
430
+ */
431
+ export function createQualityMenu({
432
+ player,
433
+ button,
434
+ qualities,
435
+ currentQuality,
436
+ isHLS,
437
+ onQualitySelect,
438
+ insertIntoDOM,
439
+ positionMenu,
440
+ attachCloseHandler
441
+ }: QualityMenuOptions): HTMLElement | null {
442
+ const items: ActionMenuItem[] = [];
443
+
444
+ // Auto option for HLS
445
+ if (isHLS) {
446
+ items.push({
447
+ text: i18n.t('player.auto'),
448
+ value: -1,
449
+ active: currentQuality === -1,
450
+ onClick: () => onQualitySelect(-1)
451
+ });
452
+ }
453
+
454
+ // Quality options
455
+ qualities.forEach(quality => {
456
+ items.push({
457
+ text: quality.name || `${quality.height}p`,
458
+ value: quality.index,
459
+ active: quality.index === currentQuality,
460
+ onClick: () => onQualitySelect(quality.index)
461
+ });
462
+ });
463
+
464
+ if (items.length === 0) {
465
+ items.push({
466
+ text: i18n.t('player.autoQuality'),
467
+ disabled: true
468
+ });
469
+ }
470
+
471
+ return createMenu({
472
+ player,
473
+ button,
474
+ menuClass: 'quality-menu',
475
+ ariaLabel: i18n.t('player.quality'),
476
+ items,
477
+ radioGroup: true,
478
+ insertIntoDOM,
479
+ positionMenu,
480
+ attachCloseHandler
481
+ });
482
+ }
483
+